# Licensed under a 3-clause BSD style license - see LICENSE.rst

import copy
from functools import reduce
import operator
import os
from warnings import warn

import astropy.units as u
import astropy.io.fits as fits
import numpy as np
import numpy.ma as ma
import synphot as syn

from . import config as cf
from .constants import PANDEIA_WAVEUNITS, PANDEIA_FLUXUNITS
from .custom_exceptions import EngineOutputError, EngineInputError
from . import debug_utils
from .detector import H2RG, H4RG, SiAs, CCD, H1R, MAMA, XDL
from .instrument import check_warnings
from .io_utils import read_json
from .hst import HSTInstrument, COS
from .pandeia_warnings import report_warning_messages as warning_messages
from .projection import ProjectionFactory
from .utils import recursive_subclasses

default_refdata_directory = cf.default_refdata_directory

def ReportFactory(calc_input, signal_list, noise_list, saturation_list, extracted, warnings, sub_str=''):


    """
    Function to take strategy and build/return a configured instance of the appropriate report. For ease of
    identifying and making targeted changes to a single strategy when necessary, we define subclasses for
    every strategy.

    Parameters
    ----------
    calc_input: dict
        The input calculation dictionary
    signal_list: list
        List of all the signal objects (one per dither)
    noise_list: list
        List of all the noise objects (one per dither)
    saturation_list: list
        List of all the saturation objects (one per dither)
    extracted: dict
        Dictionary of all the extracted observation products
    warnings: dict
        Dictionary of warning messages produced by the code
    sub_str: string
        String for tagging warnings from different subreports

    Returns
    -------
    cls: Report class object
        Properly configured Report class for the given strategy
    """

    method = calc_input['strategy']['method']

    # we need to run the API checks here after merging in the report-specific defaults
    #if webapp:
    #    self._api_checks(all_config)

    types = recursive_subclasses(Report)
    methods = [t.__name__.lower().replace('report', '') for t in types]
    type_map = dict(list(zip(methods, types)))

    if method not in methods:
        msg = "Unsupported or not yet implemented strategy: %s" % method
        raise EngineInputError(value=msg)
    else:
        cls = type_map[method](calc_input, signal_list, noise_list, saturation_list, extracted, warnings, sub_str)
        return cls


class Report:

    """
    This class contains functionality for generating reports and data products from the results
    of a Pandeia calculation.

    Parameters
    ----------
    calc_input: dict
        The engine API format dict used to configure the calculation
    signal_list : list of etc3D.DetectorSignal instances
        The calculated signal on the detector
    noise_list : list of etc3D.DetectorNoise instances
        The calculated noise on the detector
    extracted : dict
        The extracted data product generated by the Strategy
    warnings: dict
        Dict of warnings produced and collected through the course of the calculation
    sub_str: str
        String for tagging warnings from different subreports
    """

    def __init__(self, calc_input, signal_list, noise_list, saturation_list, extracted_list, warnings, sub_str):
        # For single pointing calculations, the signal and noise lists have one element.
        # However, handling calculations that involve multiple pointings is more tricky.
        # We want to grab one of the on_target calculations to get the relevant target
        # information for the report.
        if not isinstance(signal_list, list):
            raise EngineOutputError(value="Must provide Report signals and noises as lists.")

        self.r = dict()

        self.warnings = warnings
        # We use the calculation inputs for a few things.
        self.input = calc_input

        self.r['sub_reports'] = []

        if len(signal_list) == 1:
            self.signal = signal_list[0]
            self.noise = noise_list[0]
            self.saturation = saturation_list[0]
            self.extracted = extracted_list[0]
        elif len(signal_list) > 1:
            # Fish out the list of which ones are targets.
            """
            on_target = calc_input['strategy']['on_target']  # True if target, False otherwise
            target = [i for i, v in enumerate(on_target) if v][0]  # get first target
            """
            # The above method does not work in general. Assume the first plane is a target and
            # refactor when implementing dithers in general.
            target = 0

            self.signal = signal_list[target]
            self.noise = noise_list[target]
            self.saturation = saturation_list[target]
            self.extracted = extracted_list[target]
            # the zeroth element in the extracted_list is actually the combined results (where they exist) followed by
            # the same planes that are in the signal, noise, and saturation lists. We need to remove it so they line up.
            # if the extracted results are not from a multi-pointing mode, they are identical to the zeroth elements of
            # the signal, noise, and saturation arrays.
            extracted_list.pop(0)  # we want the subreports to have the individual subreport extractions
            subreports = range(len(signal_list))
            for signal, noise, saturation, extracted, subreport in zip(signal_list, noise_list, saturation_list, extracted_list, subreports):
                s = ReportFactory(calc_input, [signal], [noise], [saturation], [extracted], warnings, '{0:d}'.format(subreport+1))
                self.r['sub_reports'].append(s.as_dict())

        self.sub_str = sub_str

        # Get the full Signal and Noise objects for inspection.
        self.signals = signal_list
        self.noises = noise_list
        self.saturations = saturation_list
        self.extracts = extracted_list

        self.tel = self.signal.current_instrument.telescope.tel_name
        self.inst = self.signal.current_instrument.inst_name
        self.mode = self.signal.current_instrument.mode
        self.strategy = self.input['strategy']['method']

        self.wave_index = 0
        self.projection_type = self.signal.projection_type
        self.projection = ProjectionFactory(self.projection_type)
        self._projection()

        # make a deep copy of the input so that nothing else can modify it later on
        self.r['input'] = copy.deepcopy(self.input)
        # initializing the rest of these as empty dictionaries to be filled
        self.r['1d'] = {}
        self.r['2d'] = {}
        self.r['3d'] = {}
        self.r['scalar'] = {}
        self.r['information'] = {}
        self.r['transform'] = {}

        # now fill the above dictionary sections with data
        self._information()
        self._transform()
        self._scalar()
        self._1d()
        self._debugarrays()
        self._2d()
        self._3d()

        if isinstance(self.signal.current_instrument, HSTInstrument):
            self.r['bop'] = {}
            self._bop()

            # Some HST instruments report in counts/s as opposed to electrons/s. Check the values accordingly.
            self.r["scalar"]["is_counts"] = False           
            if hasattr(self.signal.current_instrument.the_detector, "report_counts"):
                if self.signal.current_instrument.the_detector.report_counts:
                    self.r['scalar']['is_counts'] = True
                    self.convert_electrons_to_counts(calc_input)
            self.r['scalar']['obsmode'] = self.signal.current_instrument.obsmode

            if self.signal.current_instrument.instrument["mode"] == "ccd_imagingacq":
                self.r['scalar']['extracted_total_flux'] = self.r['scalar']['extracted_flux'] * self.signal.the_detector.exposure_spec.exposure_time

            # specially needed for WFC3 and STIS
            # the warning test that needs this checks per-independent-exposure.
            area = self.r['scalar']['background_area']
            if not isinstance(area, (float, int)): # Will be None if there's no background subtraction region
                area = self.r['scalar']['extraction_area']
            self.r['scalar']['background_sky_count_pixel'] = self.r['scalar']['background_sky'] * self.signal.the_detector.exposure_spec.exposure_time / area
            # ACS Ramp needs this
            pixscl_y, pixscl_x = self.signal.current_instrument.get_pix_size()
            self.r['scalar']['background_total_area'] = self.r['scalar']['background_total'] / area / (pixscl_y * pixscl_x)

            if 'brightest_pixel_rate' in self.r['bop']:
                bright_detector = max(zip(self.r['bop']['brightest_pixel_rate'].values(), self.r['bop']['brightest_pixel_rate'].keys()))[1]
                # get the brightest pixel per read in to the main report
                #self.r['scalar']['brightest_pixel'] = self.r['bop']['brightest_pixel_rate'][bright_detector][0]
                # get the brightest pixel count in to the main report
                self.r['scalar']['brightest_pixel_count'] = self.r['bop']['brightest_pixel_rate'][bright_detector][0] * self.signal.the_detector.exposure_spec.exposure_time

                # Copy brightest pixel wavelength, brightest pixel rate and detector total rate into the main report as well.
                self.r['scalar']['brightest_pixel_wavelength'] = self.r['bop']['brightest_pixel_rate'][bright_detector][1]
                self.r['scalar']['brightest_pixel_rate'] = self.r['bop']['brightest_pixel_rate'][bright_detector][0]
                self.r['scalar']['detector_total_rate'] = self.r['bop']['max_total_detector_rate']


            if isinstance(self.signal.current_instrument, COS):
                if not self.signal.single_point_source:
                    key="cos_extended_multiple"
                    self.warnings[key] = warning_messages[key]

            self._warnings()

            self._check_output_warnings(signal_list[0].current_instrument.warning_list_output)
            self.r["bop"]["warnings"] = {}

            if 'imag' in self.strategy:
                if len(self.input['scene']) > 1 or \
                   len(self.input['scene']) == 1 and (self.input['scene'][0]['position']['x_offset'] != 0.0 or self.input['scene'][0]['position']['y_offset'] != 0.0):
                    # BOP calculations are only accurate for scenes with a single, centered source.
                    # Otherwise, display a BOP warning.
                    key = 'bop_single_centered_source'
                    self.r['bop']['warnings'][key] = warning_messages[key]

            self._check_output_warnings_bop(self.signal.current_instrument.warning_list_bop)

            if len(self.r["bop"]["warnings"]) > 0:
                msg = "\033[1;31mWARNING: \033[1;33mThis calculation has critical Brightness Limit warnings. Check ['bop']['warnings'] dict in the result.\033[0m"
                warn(msg, FutureWarning)
        else:
            self._warnings()
            # And now, things that should NOT be done for HST
            # We do not want to display filter leak warnings for anyone but HST.
            warnings = self.r["warnings"]
            # we can't remove from a dict while we loop over it, though; so let's just rebuild it.
            self.r["warnings"] = {}
            for warning in warnings:
                if "filter_leak" not in warning:
                    self.r["warnings"][warning] = warnings[warning]

        # this must be run last, after the rest of the report has been filled.
        self._web_report()

    def _check_output_warnings(self, warning_list_output, sub_word=None, sub_str=''):

        # merge together input and results dictionaries so the check_warnings
        # function can find everything it needs.
        merged_dict = copy.deepcopy(self.input)
        merged_dict.update(self.r)

        # Each thing we want to be able to get the value of in the merged dictionary
        # should be listed here. The "value" part is a list of nested keys to get the actual value.
        #
        # Note that we can get in trouble when a parameter has a name that is part of a message
        # string substitution token. For example: if we name our variable 'detector_total_rate'
        # and add a reference to it to a message string, as $detector_total_rate, the string
        # substitution code will replace it with the detector name (if the detector name is one of
        # the selection parameters), resulting in the string "FUVMAMA_total_rate" showing up in
        # the output (example taken from STIS).
        value_lookup = {
            'instrument': ['configuration', 'instrument', 'instrument'],
            'mode': ['configuration', 'instrument', 'mode'],
            'detector': ['configuration', 'instrument', 'detector'],
            'disperser': ['configuration', 'instrument', 'disperser'],
            'gain': ['configuration', 'detector', 'gain'],
            'bin_spatial': ['configuration', 'detector', 'bin_spatial'],
            'bin_dispersion': ['configuration', 'detector', 'bin_dispersion'],
            'reference_wavelength': ['strategy', 'reference_wavelength'],
            'target_total_rate': ['scalar', 'extracted_flux'],
            'time': ['scalar', 'saturation_time'],
            'scan_rate': ['scalar', 'scan_rate'],
            'background_sky_count_pixel': ['scalar', 'background_sky_count_pixel'],
            'brightest_pixel_count': ['bop', 'max_bright_pixel_count'],
            'brightest_pixel_rate': ['bop', 'max_bright_pixel_rate'],
            #
            #TODO placeholder for after JETC-2421 gets merged
            'brightest_segment_rate': ['bop', 'max_bright_pixel_rate'],
            #
            'det_total_rate': ['bop', 'max_total_detector_rate'],
            'total_leak': ['scalar', 'total_leak'],
            'buffer_time': ['scalar', 'buffer_time'],
            'sn': ['scalar', 'sn'],
            'n_partial_saturated': ['scalar', 'n_partial_saturated'],
            'n_full_saturated': ['scalar', 'n_full_saturated'],
            'n_cube_partial_saturated': ['scalar', 'n_cube_partial_saturated'],
            'n_cube_full_saturated': ['scalar', 'n_cube_full_saturated'],
            'n_total_saturated': ["scalar", "n_total_saturated"],
            'max_saturated_pixels': ["scalar", "max_saturated_pixels"],
            'min_snr_threshold': ["scalar", "min_snr_threshold"]
        }

        check_warnings(merged_dict, self.warnings, warning_list_output, value_lookup, sub_word=sub_word, sub_str=sub_str)

    def _check_output_warnings_bop(self, warning_list_output_bop, sub_word=None, sub_str=''):

        # merge together input and results dictionaries so the check_warnings
        # function can find everything it needs.
        merged_dict = copy.deepcopy(self.input)
        merged_dict.update(self.r)

        # Each thing we want to be able to get the value of in the merged dictionary
        # should be listed here. The "value" part is a list of nested keys to get the actual value.
        #
        # Note that we can get in trouble when a parameter has a name that is part of a message
        # string substitution token. For example: if we name our variable 'detector_total_rate'
        # and add a reference to it to a message string, as $detector_total_rate, the string
        # substitution code will replace it with the detector name (if the detector name is one of
        # the selection parameters), resulting in tye string "FUVMAMA_total_rate" showing up in
        # the output (example taken from STIS).
        value_lookup = {
            'instrument': ['configuration', 'instrument', 'instrument'],
            'mode': ['configuration', 'instrument', 'mode'],
            'detector': ['configuration', 'instrument', 'detector'],
            'disperser': ['configuration', 'instrument', 'disperser'],
            'gain': ['configuration', 'detector', 'gain'],
            'bin_spatial': ['configuration', 'detector', 'bin_spatial'],
            'bin_dispersion': ['configuration', 'detector', 'bin_dispersion'],
            'reference_wavelength': ['strategy', 'reference_wavelength'],
            'target_total_rate': ['scalar', 'extracted_flux'],
            'time': ['scalar', 'saturation_time'],
            'scan_rate': ['scalar', 'scan_rate'],
            'background_sky_count_pixel': ['scalar', 'background_sky_count_pixel'],
            'brightest_pixel_count': ['bop', 'max_bright_pixel_count'],
            'brightest_pixel_rate': ['bop', 'max_bright_pixel_rate'],
            #
            #TODO placeholder for after JETC-2421 gets merged
            'brightest_segment_rate': ['bop', 'max_bright_pixel_rate'],
            #
            'det_total_rate': ['bop', 'max_total_detector_rate'],
            'total_leak': ['scalar', 'total_leak'],
            'buffer_time': ['scalar', 'buffer_time'],
        }

        check_warnings(merged_dict, self.r["bop"]["warnings"], warning_list_output_bop, value_lookup, sub_word=sub_word, sub_str=sub_str)

    def as_dict(self):
        """
        Functions to return the internally-created dictionary.
        It is possible to access the report dictionary attribute directly, but for backwards compatibility we've
        kept this function.

        Returns
        -------
        self.r : dict
            output dictionary of results
        """
        return self.r

    def as_fits(self):
        """
        Returns a dictionary of fits headers.
        This cannot be a separate function because it requires several attributes of the class.

        Returns
        -------
        self.f : dict
            output dictionary of fits tables
        """

        sub_reports = []
        if len(self.r['sub_reports']) > 1:
            for self.report in range(len(self.r['sub_reports'])):
                self.s = self.r['sub_reports'][self.report]
                self.f = dict()
                self.f['3d'] = {}
                self.f['2d'] = {}
                self.f['1d'] = {}
                self._3dfits()
                self._2dfits()
                self._1dfits()
                sub_reports.append(self.f)

        self.report = 0
        self.s = self.r
        self.f = dict()
        self.f['3d'] = {}
        self.f['2d'] = {}
        self.f['1d'] = {}
        self._3dfits()
        self._2dfits()
        self._1dfits()
        self.f['sub_reports'] = sub_reports

        return self.f

    def _set_wave(self):
        """
        Find and set the wavelength to extract scalar values

        Parameters
        ----------
        wref: float
            Wavelength desired for the slice (in microns)
        """
        if len(self.wave_pix) == 1:
            self.rw = self.wave_pix[0]
        else:
            self.rw = (np.min(self.wave_pix) + np.max(self.wave_pix))/2.0
            if "reference_wavelength" in self.input["strategy"]:
                wref = self.input["strategy"]["reference_wavelength"]
                if wref is not None:
                    if wref >= self.wave_pix.min() and wref <= self.wave_pix.max():
                        self.rw = wref
                    else:
                        self.warnings["bad_waveref"] = warning_messages["bad_waveref"].format(wref,self.wave_pix.min(),
                                                                                        self.wave_pix.max(),
                                                                                        self.rw)
                # Not setting a reference wavelength is legitimate
            else:
                self.warnings["no_waveref"] = warning_messages["no_waveref"].format(self.rw)
        if not hasattr(self.signal.the_detector, "pixels_per_resel"):
            wave_index = (np.abs(self.wave_pix - self.rw)).argmin()
            self.rw = self.wave_pix[wave_index]

    def _3dfits(self):
        """
        Format 3D outputs into fits headers.
        """
        for k in ['flux', 'flux_plus_background']:
            self.f['3d'][k] = []
            for i in range(len(self.s['3d'][k])):
                # the cube data coming out of pandeia.engine is ordered wavelength,x,y which is
                # backwards from the numpy convention of ordering axes from slowest to fastest.
                # it is set up this way because we need to broadcast 1D vectors (e.g. throughput
                # vs. wavelength) onto the 3D cubes. for this to work, wavelength must be the 3rd
                # axis. so flip the dimensions of the cube and then flip the Y axis to get the expected
                # orientation in the FITS data as viewed in DS9 or the like.
                o = fits.PrimaryHDU(np.moveaxis(self.s['3d'][k][i],2,0)[:, ::-1, :])
                # it should not be different, but pull the wcs info from the correct signal
                tbhdu, header = self.signals[self.report].cube_wcs_info()
                o.header.update(header)
                self.f['3d'][k].append(fits.HDUList([o, tbhdu]))

    def _2dfits(self):
        """
        Format 2D outputs into fits headers.
        """
        # this is set by _projection, and should be the same for all subreports of a single calculation
        detector_header = self.pix_grid.wcs_info()
        for k in self.s['2d']:
            # flip the Y axis to get the FITS data to look the
            # same in DS9 as in matplotlib.
            o = fits.PrimaryHDU(self.s['2d'][k][::-1, :])
            o.header.update(detector_header)
            self.f['2d'][k] = o

    def _1dfits(self):
        """
        Format 1D outputs into fits headers.
        """

        # These two are special one-array lists
        for k in ['wave_calc', 'wave_pix']:
            tbhdu = fits.BinTableHDU.from_columns([
                fits.Column(name='WAVELENGTH',
                            unit='um',
                            format="1D",
                            array=self.s['1d'][k])
            ])
            tbhdu.name = k
            self.f['1d'][k] = fits.HDUList([tbhdu])

        # now go through the rest
        # note that we're removing them from a list of dictionary keys, not removing them from the actual dictionary.
        results1d = list(self.s['1d'].keys())
        results1d.remove('wave_calc')
        results1d.remove('wave_pix')
        for k in results1d:
            tbhdu = fits.BinTableHDU.from_columns([
                fits.Column(name='WAVELENGTH',
                            unit='um',
                            format="1D",
                            array=self.s['1d'][k][0]),
                fits.Column(name=k,
                            format="1D",
                            array=self.s['1d'][k][1])
            ])
            tbhdu.name = k
            self.f['1d'][k] = fits.HDUList([tbhdu])

    def _projection(self):
        """
        Run the appropriate projection class. This is often overridden by strategies to include additional code.
        """
        self.grid, self.wave_pix, self.pix_grid = self.projection._project(self.signal, self.extracted)

    def _transform(self):
        """
        Spatial and wavelength information taken from the coordinate transform products
        """

        # set up the coordinate transform information that describes the image axes
        # for the 2D and 3D data.  first, get the spatial information from self.Grid

        self.r['transform'] = self.pix_grid.as_dict()

        # the wavelength sampling of the input spectrum is different
        # then the sampling by the detector. the model cubes that are
        # used to make the calculation use the input sampling. the detector
        # plane outputs for spectroscopy and IFU use the detector sampling

        # ## input model wavelength sampling ## #
        self.r['transform'].update(self.signal.spectral_model_transform())

        # ## detector plane wavelength sampling ## #
        self.r['transform'].update(self.signal.spectral_detector_transform())

        # the spectral mapping can be shifted by strategies (e.g. slitless). this is
        # reflected in wave_pix so update from there.
        self.r['transform']['wave_det_min'] = self.wave_pix.min()
        self.r['transform']['wave_det_max'] = self.wave_pix.max()
        self.r['transform']['wave_det_refval'] = self.wave_pix[0]

    def _scalar(self):
        """
        Scalar data products

        Note that this method allows for certain variables to be unspecified in the inputs, on a case by case basis.
        This permissiveness is an intentional engine feature - parameters that are not given in the inputs are to be
        filled in by (sensible, astronomer-verified) defaults, and should not cause crashes. #3737
        """

        # Get the scalar wavelength to extract
        self._set_wave()

        # get areas in pixels of source and background regions
        if isinstance(self.extracted["extraction_area"], (list, np.ndarray)):
            self.r['scalar']['extraction_area'] = self.signal.the_detector.to_resels_scalar(self.wave_pix, self.extracted["extraction_area"],  self.rw)
            self.extraction_area = self.signal.the_detector.to_resels_scalar(self.wave_pix, self.extracted["extraction_area"],  self.rw)
        else:
            self.r['scalar']['extraction_area'] = self.extracted['extraction_area']
            self.extraction_area = self.extracted['extraction_area']

        self.r['scalar']['background_area'] = self.extracted['background_area']
        self.background_area = self.extracted['background_area']

        self.r['scalar']['fraction_saturation'] = self.extracted['saturation_products']['fraction_saturation']
        self.r['scalar']['sat_ngroups'] = np.min(self.extracted['detector_ngroups'])
        self.r['scalar']['brightest_pixel'] = self.signal.brightest_pixel

        # buffer time - must handle segmented and non-segmented detectors.
        if hasattr(self.signal.the_detector, "half_buffer"):
            if isinstance(self.signal.detector_total_rate, dict) and len(self.signal.detector_total_rate) > 1:
                result = {}
                detector_total_rate = 0

                for segment in self.signal.detector_total_rate:
                    result[segment] = self.signal.the_detector.half_buffer / self.signal.detector_total_rate[segment]
                    detector_total_rate += self.signal.detector_total_rate[segment]

                self.r['scalar']['segmented_buffer_time'] = result
                self.r['scalar']['buffer_time'] = self.signal.the_detector.half_buffer / detector_total_rate
            else:
                key = list(self.signal.detector_total_rate.keys())[0]
                self.r['scalar']['buffer_time'] = self.signal.the_detector.half_buffer / \
                                                  self.signal.detector_total_rate[key]

        # segmented detectors report brightest pixel per segment
        if hasattr(self.signal, 'brightest_pixel_rate'):
            if isinstance(self.signal.brightest_pixel_rate, dict)  and len(self.signal.brightest_pixel_rate) > 1:
                self.r['scalar']['segmented_brightest_pixel_rate'] = self.signal.brightest_pixel_rate

        # segmented detectors report total rate and buffer time per segment
        if hasattr(self.signal, 'detector_total_rate'):
            if isinstance(self.signal.detector_total_rate, dict) and len(self.signal.detector_total_rate) > 1:
                self.r['scalar']['brightest_segment_rate'] = max(self.signal.detector_total_rate.values())
                for key in self.signal.detector_total_rate:
                    self.r['scalar']['segment_' + key + '_total_rate'] = self.signal.detector_total_rate[key]

        # put some instrument and strategy configuration info into the scalar report
        self.r['scalar']['filter'] = self.input['configuration']['instrument']['filter']
        self.r['scalar']['disperser'] = self.input['configuration']['instrument']['disperser']
        if "cenwave" in self.input['configuration']['instrument']:
            self.r['scalar']['cenwave'] = self.input['configuration']['instrument']['cenwave']
        if "slit" in self.input['configuration']['instrument']:
            self.r['scalar']['slit'] = self.input['configuration']['instrument']['slit']
        if "postflash" in self.input['configuration']['detector']:
            self.r['scalar']['postflash'] = self.input['configuration']['detector']['postflash']

        # target_xy is not found in SOSS or MSAFullApPhot, and may not be found in future strategies as well.
        # If it's not there, assume the target position is the center of the FOV, i.e. (0.0, 0.0).
        if 'target_xy' in self.input['strategy']:
            self.r['scalar']['x_offset'] = self.input['strategy']['target_xy'][0]
            self.r['scalar']['y_offset'] = self.input['strategy']['target_xy'][1]
        else:
            self.r['scalar']['x_offset'] = 0.0
            self.r['scalar']['y_offset'] = 0.0

        # Not all strategies define an aperture size either. SOSS doesn't because the size is defined by an
        # the grism. TAPhot and TACentroid do not require it to be specified, as it is fixed and cannot be changed
        # by users. Optimal extraction strategies likely will not use this, either, once implemented.
        if 'aperture_size' in self.input['strategy']:
            self.r['scalar']['aperture_size'] = self.input['strategy']['aperture_size']

            if "aperture_sizes_arcsec" in self.extracted:
                # If the aperture is defined by the enclosed energy percentage, we need to get the aperture_size
                #  in arcseconds from the extracted quantities.
                self.r['scalar']['aperture_size_percent'] = self.input['strategy']['aperture_size']
                self.r['scalar']['aperture_size'] = self.signal.the_detector.to_resels_scalar(self.wave_pix, self.extracted["aperture_sizes_arcsec"],  self.rw)
        else:
            self.r['scalar']['aperture_size'] = "n/a"

    def _bop(self):
        """
        Compute HST's Bright Object Protection (Health and Safety) quantities
        """

        # TODO  -  JETC-2484
        # This is a temporary bail-out code that prevents bop quantities to be calculated
        # when the flux cube contains nans. Otherwise, the code downstream that expects
        # bop values, crashes. To bem removed once the ticket above gets implemented.
        if len(self.signal.brightest_pixel_rate.values()) == 0:
            return

        self.r['bop']['brightest_pixel_rate'] = self.signal.brightest_pixel_rate
        self.r['bop']['detector_total_rate'] = self.signal.detector_total_rate

        # BOP brightest pixel rate and detector total rate are provided for more than one segments
        # find the highest per-segment value and add BOP warning based on it

        detector = self.input['configuration']['instrument']['detector']
        # max brightest pixel rate from the dict with tuples for rate and wavelength at which it occurs for every chip
        time = self.signal.the_detector.exposure_spec.exposure_time # for a single exposure, taking nsplit into account
        max_bright_pixel_rate = max(self.signal.brightest_pixel_rate.values(), key = operator.itemgetter(0))[0]
        self.r['bop']['max_bright_pixel_rate'] = max_bright_pixel_rate
        self.r['bop']['max_bright_pixel_count'] = max_bright_pixel_rate * time
        self.r['bop']['max_total_detector_rate'] = max(self.signal.detector_total_rate.values())

    def _3d(self):
        """
        Compute 3D data products
        """

        # this is the data cube of the input signal (not converted to photons, with no telescope throughputs)
        self.flux = self.signal.flux_cube_list
        self.flux_plus_bg = self.signal.flux_plus_bg_list
        self.r['3d']['flux'] = self.flux

        # this is the data cube of the input signal plus background
        self.r['3d']['flux_plus_background'] = self.flux_plus_bg

    def _2d(self):
        """
        Compute 2D data products. Though some strategies override this, these are mostly projection-dependent
        """
        resultdict,self.bg_pix = self.projection._2d(self.extracted, self.saturation, self.signal)
        self.r['2d'].update(resultdict)

    def _1d(self):
        """
        Compute 1D data products, and the scalar values that are extracted from the 1D products.

        NOTE: We do not do any NaN checking here.  This may cause certain values like SNR
        to be reported as NaN in 1d, but as 0 in scalars.
        """

        # this is the wavelength sampling of the calculation
        self.wave = self.signal.wave

        self.r['1d']['wave_pix'] = self.wave_pix
        self.r['1d']['wave_calc'] = self.wave

        # Target spectrum
        self.r['1d']['target'] = [self.wave, self.signal.total_flux]

        # Transmission/focal plane rate
        self.r['1d']['fp'] = [self.wave, self.signal.total_rate[0]]

        # input background
        self.r['1d']['bg'] = [self.wave, self.signal.background.MJy_sr]

        # Background rate
        self.r['1d']['bg_rate'] = [self.wave, self.signal.bg_fp_rate]

        # Values calculated by the strategy
        sn = self.extracted['extracted_flux'] / self.extracted['extracted_noise']

        self.r['1d']['sn'] = [self.wave_pix, sn]
        self.r['1d']['extracted_noise'] = [self.wave_pix, self.extracted['extracted_noise']]
        self.r['1d']['extracted_flux'] = [self.wave_pix, self.extracted['extracted_flux']]
        self.r['1d']['extracted_flux_plus_bg'] = [self.wave_pix, self.extracted['extracted_flux_plus_bg']]

        self.r['1d']['total_flux'] = [self.wave_pix, self.extracted['source_flux_in_fov']]

        self.r['1d']['extracted_bg_total'] = [self.wave_pix, self.extracted['extracted_bg_total']]
        self.r['1d']['extracted_bg_only'] = [self.wave_pix, self.extracted['extracted_bg_only']]
        self.r['1d']['extracted_contamination'] = [self.wave_pix, self.extracted['extracted_bg_total'] - self.extracted['extracted_bg_only']]
        self.r['1d']['n_partial_saturated'] = [self.wave_pix, self.extracted['saturation_products']['partial']]
        self.r['1d']['n_full_saturated'] = [self.wave_pix, self.extracted['saturation_products']['full']]

        self.curves = self.r['1d']

        # Values calculated by the strategy
        # CHECK: if this used dispersion binning, set resels to the minimum of (1, Detector.pixels_per_resel/dispersion_binning)
        if hasattr(self.signal.the_detector, "pixels_per_resel"):
            if hasattr(self.signal.the_detector, "bin_dispersion") and self.signal.the_detector.bin_dispersion != 1:
                self.signal.the_detector.pixels_per_resel = np.min(1, self.signal.the_detector.pixels_per_resel/self.signal.the_detector.bin_dispersion)
            self.r['1d']['extracted_noise_resel'] = self.signal.the_detector.to_resels_array(self.wave_pix, self.extracted['extracted_noise'])
            self.r['1d']['extracted_flux_resel'] = self.signal.the_detector.to_resels_array(self.wave_pix, self.extracted['extracted_flux'])
            self.r['1d']['extracted_flux_plus_bg_resel'] = self.signal.the_detector.to_resels_array(self.wave_pix, self.extracted['extracted_flux_plus_bg'])

            self.r['1d']['extracted_bg_total_resel'] = self.signal.the_detector.to_resels_array(self.wave_pix, self.extracted['extracted_bg_total'])
            self.r['1d']['extracted_bg_only_resel'] = self.signal.the_detector.to_resels_array(self.wave_pix, self.extracted['extracted_bg_only'])
            self.r['1d']['extracted_contamination_resel'] = self.signal.the_detector.to_resels_array(self.wave_pix, self.extracted['extracted_bg_total'] - self.extracted['extracted_bg_only'])
            self.r['1d']['n_partial_saturated_resel'] = self.signal.the_detector.to_resels_array(self.wave_pix, self.extracted['saturation_products']['partial'])
            self.r['1d']['n_full_saturated_resel'] = self.signal.the_detector.to_resels_array(self.wave_pix, self.extracted['saturation_products']['full'])

            sn = self.r['1d']['extracted_flux_resel'][1] / self.r['1d']['extracted_noise_resel'][1]
            self.r['1d']['sn_resel'] = [self.r['1d']['extracted_flux_resel'][0], sn]

        # Get the scalar wavelength to extract
        self._set_wave()

        signal = self.signal.the_detector.to_resels_scalar(self.wave_pix, self.extracted['extracted_flux'], self.rw)
        noise = self.signal.the_detector.to_resels_scalar(self.wave_pix, self.extracted['extracted_noise'], self.rw,
                                                          squared=True)
        self.r['scalar']['sn'] = signal/noise
        if not np.isfinite(self.r['scalar']['sn']):
            self.r['scalar']['sn'] = 0.0
        self.r['scalar']['extracted_flux'] = signal

        # if the SN is 0.0 (e.g. due to heavy saturation), we do not know what the variance is and set it to np.nan
        if np.abs(self.r['scalar']['sn']) > 0.0:
            self.r['scalar']['extracted_noise'] = noise
        else:
            self.r['scalar']['extracted_noise'] = np.nan

        self.r['scalar']['background_total'] = self.signal.the_detector.to_resels_scalar(self.wave_pix, self.extracted['extracted_bg_total'], self.rw)
        self.r['scalar']['background_sky'] = self.signal.the_detector.to_resels_scalar(self.wave_pix, self.extracted['extracted_bg_only'], self.rw)

        # only check for contamination if background level is above some epsilon value. otherwise round-off
        # errors can cause unstable test results.
        if np.abs(self.r['scalar']['background_total']) > 1.0e-9:
            self.r['scalar']['contamination'] = (self.r['scalar']['background_total'] - self.r['scalar']['background_sky']) / \
                                                self.r['scalar']['background_total']
        else:
            self.r['scalar']['contamination'] = 0.0

        self.r['scalar']['reference_wavelength'] = self.rw
        self.r['scalar']['background'] = self.signal.background.bg_spec(self.rw * PANDEIA_WAVEUNITS, flux_unit=u.MJy).value


    def _information(self):
        """
        Collect some additional information that is not the product of a calculation.
        """

        # get the ExposureSpecification.
        self.exposure_specification = self.signal.the_detector.exposure_spec

        self.r['information']['calc_type'] = self.signal.projection_type

        self.r['information']['exposure_specification'] = self.exposure_specification.__dict__

        # Some strategies like IFU and coronagraphy add dithers. Some are on-source, others are off-source.
        # This ensures that all on-source dithers are included in the "total_exposure_time", whereas
        # off-source time is only reported in "all_dithers_time".
        total_exposure_time = self.r['information']['exposure_specification']['total_exposure_time']
        if 'n_on_source' in self.extracted:
            self.r['scalar']['total_exposure_time'] = total_exposure_time * self.extracted['n_on_source']
            self.r['scalar']['all_dithers_time'] = total_exposure_time * self.extracted['n_total']
        else:
            self.r['scalar']['total_exposure_time'] = total_exposure_time
            self.r['scalar']['all_dithers_time'] = total_exposure_time

        # Report on measurement, exposure time (time for a single exposure) and saturation time.
        # The r['scalar']['exposure_time'] value reported back to the client is not used at this point.
        self.r['scalar']['exposure_time'] = self.r['information']['exposure_specification']['exposure_time']
        self.r['scalar']['measurement_time'] = self.r['information']['exposure_specification']['measurement_time']
        self.r['scalar']['saturation_time'] = self.r['information']['exposure_specification']['saturation_time']
        self.r['scalar']['total_integrations'] = self.r['information']['exposure_specification']['total_integrations']

        # Report the exposure duty cycle
        self.r['scalar']['duty_cycle'] = self.r['information']['exposure_specification']['duty_cycle']

        # get the pixel CR rate in events/s and convert to events/ramp
        self.r['scalar']['cr_ramp_rate'] = self.noise.pix_cr_rate * self.r['information']['exposure_specification'][
            'saturation_time']

    def _warnings(self, sub_words={'': None, '1':'Dither','2':'Dither', '3': 'Dither'}):
        """
        Collect warnings, and generate saturation warnings

        This module contains both the collected warnings from elsewhere in the program, and also the saturation
        warnings (which are, currently, the only warnings that may be different between subreports)

        Parameters
        -----------
        sub_words : dict
            dict of strings to use when differentiating warning subreports
        """

        # The only time the saturation warnings do NOT come from self.extracted['detector_saturation'] is with the
        # IFU modes, which override this method.
        sat_obj = self.extracted["detector_saturation"]
        self.r["warnings"] = self.warnings

        self.r["scalar"]["n_partial_saturated"] = (sat_obj == 1).sum()
        self.r["scalar"]["n_full_saturated"] = (sat_obj == 2).sum()

        if not isinstance(self.signal.the_detector, (MAMA, XDL)):
            self.r["scalar"]["fullwell"] = self.signal.the_detector.fullwell


        self._check_output_warnings(self.signal.current_instrument.warning_list_output, sub_word=sub_words[self.sub_str], sub_str=self.sub_str)

        # these properties were only needed for warning generation
        del self.r["scalar"]["n_partial_saturated"]
        del self.r["scalar"]["n_full_saturated"]

        if not isinstance(self.signal.the_detector, (MAMA, XDL)):
            del self.r["scalar"]["fullwell"]


    def _debugarrays(self):
        """
        Collect the debug information if it exists
        """
        if debug_utils.debugarrays and debug_utils.debugarrays.get_report():
            self.r['debugarrays'] = debug_utils.debugarrays.get_report()


    def convert_electrons_to_counts(self, calc_input):
        """
        This is an HST instrument and gain is present, so convert electrons to counts for the relevant keys.
        gain = electrons / counts --> counts = electrons / gain

        Outputs will be in counts/s
        """
        gain = float(calc_input['configuration']['detector'].get('gain',1.0))

        if 'background_total' in self.r['scalar']:
            self.r['scalar']['background_total'] = self.r['scalar']['background_total'] * gain
            self.r['scalar']['background_sky'] = self.r['scalar']['background_sky'] * gain
        if 'brightest_pixel' in self.r['scalar']:
            self.r['scalar']['brightest_pixel'] = self.r['scalar']['brightest_pixel'] * gain
        if 'extracted_flux' in self.r['scalar']:
            self.r['scalar']['extracted_flux'] = self.r['scalar']['extracted_flux'] * gain
        if 'extracted_noise' in self.r['scalar']:
            self.r['scalar']['extracted_noise'] = self.r['scalar']['extracted_noise'] * gain

        if 'brightest_pixel_rate' in self.r['bop']:
            for detector in self.r['bop']['brightest_pixel_rate']:
                self.r['bop']['brightest_pixel_rate'][detector][0] = self.r['bop']['brightest_pixel_rate'][detector][0] * gain

        if 'detector_total_rate' in self.r['bop']:
            for key, value in self.r['bop']['detector_total_rate'].items():
                self.r['bop']['detector_total_rate'][key] = self.r['bop']['detector_total_rate'][key] * gain

############ The pretty-printed web report #################

    def _web_report(self):
        """
        Create a pretty-formatted report for UI (or other) display

        This operates on a dictionary that defines what the output should look like, which is
        produced by _get_report_config.

        For the most part, the only part of the dictionary that needs to be changed between the engine definition
        and web UI output format is that the keys need to be replaced with their corresponding value.
        """
        report_config, inst_precision = self._get_report_config()
        # web_report is a list to preserve ordering
        self.r['web_report'] = []
        # loop through all the categories
        for category in report_config:
            # the items in each category are a list to preserve ordering
            category_dict = {"category": category, "items": []}
            # special keywords go here, and must be removed before we process rows
            if "color" in report_config[category]:
                category_dict["color"] = report_config[category]["color"]
                del report_config[category]["color"]

            # Now process the rows
            for item in range(len(report_config[category])):
                # start building the ending statement
                insertion = {}

                # 1. Get the name, which may be a dict whose choice depends on strategy
                if isinstance(report_config[category][item]["name"], dict):
                    if self.strategy in report_config[category][item]["name"]:
                        row_name = report_config[category][item]["name"][self.strategy]
                    else:
                        row_name = report_config[category][item]["name"]["default"]
                else:
                    row_name = report_config[category][item]["name"]
                insertion["name"] = row_name

                # 2. (optional) color formatting
                if "color" in report_config[category][item]:
                    insertion["color"] = report_config[category][item]["color"]

                # If there is a keylist we have a value(s) to extract
                if "key" in report_config[category][item]: # we will not always have keys for a value, so as to allow plain text to appear in the outputs.
                    precision = inst_precision
                    if "precision" in report_config[category][item]:
                        precision = report_config[category][item]["precision"]
                    # 3. Get the unit, which may be a dict whose choice depends on mode
                    if isinstance(report_config[category][item]["unit"], dict):
                        if self.mode in report_config[category][item]["unit"]:
                            row_unit = report_config[category][item]["unit"][self.mode]
                        else:
                            row_unit = report_config[category][item]["unit"]["default"]
                        # because we do occasionally reload the name from report_config object, we do need to change both of them
                    elif report_config[category][item]["unit"] == "set-by-software":
                        # Some items have units that depend on user inputs.  Set the units for those items here.
                        if report_config[category][item]["key"][0][1] == "aperture_size":
                            row_unit = self.input['strategy']['units']
                            if row_unit != 'arcsec':
                                row_unit += 's'
                    else:
                        row_unit = report_config[category][item]["unit"]
                    insertion["unit"] = row_unit

                    # 4. Now walk down the keylist to get the value
                    valuelist = []
                    # 4a. we will either have multiple keys per value (ex: target position) OR multiple values per key (ex: COS Spectroscopy)
                    if len(report_config[category][item]["key"]) > 1:
                        for keyset in report_config[category][item]["key"]:
                            val = self._key_walk(keyset, row_unit, precision)
                            valuelist.append(val.strip())
                        row_value = f"{', '.join(valuelist)}"
                    else:
                        row_value = self._key_walk(report_config[category][item]["key"][0], row_unit, precision)

                    # 4b. if the row_value is not a list, it's a single value to be added. 
                    if not isinstance(row_value,list):
                        insertion["value"] = row_value
                        insertion_list = [insertion]
                    else:
                        # If the row_value is a list, it will be a list of dicts with name, value pairs to differentiate detectors.
                        insertion_list = []
                        for single_value in row_value:
                            # Only the combined value will be formatted to allow us to insert the original name.
                            # This step is different because we have multiple outputs that each become their own line.

                            new_insertion = copy.deepcopy(insertion)
                            new_insertion["name"] = single_value["name"].format(row_name)
                            new_insertion["value"] = single_value["val"] 
                            new_insertion["indent"] = single_value["indent"]
                            insertion_list.append(new_insertion)
                else:
                    # No key, so just display the text.
                    insertion_list = [insertion]
                category_dict["items"].extend(insertion_list)
            self.r["web_report"].append(category_dict)


    def _get_report_config(self):
        """
        Set up formatting for this calculation
        """
        tel_config_path = os.path.join(default_refdata_directory, self.tel, "telescope", "report_config.json")
        inst_config_path = os.path.join(default_refdata_directory, self.tel, self.inst, "report_config.json")
        tel_config = read_json(tel_config_path, raise_except=True)
        inst_config = read_json(inst_config_path, raise_except=True)

        inst_precision = tel_config["precision"]
        if "precision" in inst_config:
            inst_precision = inst_config["precision"]
            del inst_config["precision"]
        report_config = {category: tel_config[category]['default'] for category in tel_config if category not in ["precision", "-delivered-for-version-"]}

        # Order of precedence: (on a section-by-section basis)
        # 1. Mode-specific section of the inst configuration
        # 2. Inst-specific config
        # 3. Telescope default
        #
        # Individual modes can remove entries with a special keyword
        for category in inst_config:
            if self.mode in inst_config[category]:
                report_config[category] = inst_config[category][self.mode]
                if "delete" in report_config[category]:
                    del report_config[category]
            elif "default" in inst_config[category]:
                report_config[category] = inst_config[category]["default"]
            # else use the telescope configuration

        return report_config, inst_precision

    def _key_walk(self, keys, unit, precision):
        """
        Actually walk through the report dictionary to find the value and format it.
        Some very basic processing can be done here.

        """
        # https://stackoverflow.com/questions/9320335/accessing-python-dict-with-multiple-key-lookup-string
        try:
            val = reduce(dict.get, keys, self.r)
        except TypeError: # Failures to find the key result in a TypeError, not a KeyError.
            val = None

        # Some unit conversions here
        if unit == "Angstroms" and val is not None:
            val *= 10000

        val = self._value_process(val, precision)
        return val

    def _value_process(self, val, precision, spacing=True):
        """
        Process a value into a string according to what it is
        """
        if isinstance(val, (float, np.floating)):
            if val != 0.0 and np.log(abs(val)) < -1*precision:
                # I put a format string in our format string so we can format our format
                val = format(val, f" .{precision}e")
            else:
                val = format(val, f" .{precision}f")
        elif isinstance(val, (int, np.signedinteger)):
            val = f"{val}{' '*(precision+1)}"
        elif isinstance(val, (list,tuple, np.ndarray)):
            val = f"({','.join([self._value_process(x, precision).strip() for x in val])})"
        elif isinstance(val, dict):
            valuelist = []
            outputlist = []
            for key in val:
                if isinstance(val[key], (list, tuple)):
                    # this is a bit of hard-coding, but currently the only time the dict contains a list, it's brightest_pixel_rate
                    outputlist.append({"name": f'{key}', "val": f"{self._value_process(val[key][0], precision)} ({self._value_process(val[key][1]*10000, precision).strip()} A)", "indent": True})
                    valuelist.append(val[key][0])
                else:
                    # name each individual output after the key (aka chip/stripe)
                    outputlist.append({"name": f'{key}', "val": self._value_process(val[key], precision), "indent": True})
                    valuelist.append(val[key])
            # if there's more than one value, find the maximum and add a name that will pick up the main name
            if len(valuelist) > 1:
                outputlist.insert(0, {"name": "{}", "val": self._value_process(max(valuelist), precision), "indent": False})
                val = outputlist
            # otherwise, process as if it's just one value
            else:
                val = self._value_process(max(valuelist), precision)
        elif val is None:
            val = "n/a"
        # otherwise, it's a string and we just return it
        return val

############ End pretty-printed web report #################

class CoronagraphyReport(Report):
    """
    This report class is specifically for Coronagraphy (and its derivatives)
    Parameters are read in through Report.__init__

    """

    def _1d(self):
        """
        Coronagraphy-specific contrast quantities
        """
        Report._1d(self)
        # Coronagraphy always has contrast curves
        self.r['1d']['contrast'] = self.extracted['contrast_curve']
        self.curves['contrast'] = self.extracted['contrast_curve']

    def _1dfits(self):
        Report._1dfits(self)
        # contrast is NOT like the other 1D products and must be handled separately (overwritten).
        tbhdu = fits.BinTableHDU.from_columns([
            fits.Column(name='SEPARATION',
                        unit='arcsec',
                        format="1D",
                        array=self.s['1d']['contrast'][0]),
            fits.Column(name="CONTRAST",
                        format="1D",
                        array=self.s['1d']['contrast'][1])
        ])
        tbhdu.name = 'contrast'
        self.f['1d']['contrast'] = fits.HDUList([tbhdu])

    def _2d(self):
        """
        Coronagraphy-specific 2D calculations.

        This does not call any of the usual _2d() infrastructure (in projection.py classes) because Coronagraphy
        2D planes are different from any other mode. The coronagraphy strategy produces (and uniquely, uses) a
        detector_signal that does not have background added in. This is presumably equivalent to an astronomer
        subtracting the background off of their images before scaling and subtracting the PSF subtraction source.
        """

        # Signal and noise in 2D. Get from extracted products
        s = self.extracted['detector_signal']
        n = self.extracted['detector_noise']
        self.detector_saturation = self.extracted['detector_saturation']

        # not reported, but needs to be available as a class attribute
        self.bg_pix = self.signal.rate_plus_bg - self.signal.rate

        # The noise is undefined if the pixel is fully saturated.
        # The engine currently returns noise=0 if the pixel has full saturation, which is
        # confusing since the noise is not 0, but rather undetermined or infinite. Setting
        # the noise to NaN ensures that the S/N of saturated pixels are NaNs.
        n[self.detector_saturation == 2] = np.nan

        self.detector_sn = s / n
        self.detector_signal = s + n * np.random.randn(n.shape[0], n.shape[1])

        self.r['2d']['detector'] = self.detector_signal
        self.r['2d']['snr'] = self.detector_sn
        self.r['2d']['saturation'] = self.detector_saturation
        self.r['2d']['ngroups_map'] = self.extracted['detector_ngroups']

    def _scalar(self):
        """
        Coronagraphy-specific scalar quantities.

        By design, engine inputs should be optional. That should include this contrast information, too.
        """

        Report._scalar(self)
        if 'contrast_separation' in self.input['strategy']:
            contrast_separation = self.input['strategy']['contrast_separation']
            self.r['scalar']['contrast_separation'] = contrast_separation
            self.r['scalar']['contrast_azimuth'] = self.input['strategy']['contrast_azimuth']
            self.r['scalar']['contrast'] = np.interp(contrast_separation, self.extracted['contrast_curve'][0],
                                                self.extracted['contrast_curve'][1])

    def _warnings(self):
        Report._warnings(self, sub_words={'': None, '1':'Science Scene', '2':'PSF Subtraction Source', '3':'Contrast Observation'})

class SpecApPhotReport(Report):
    """
    This report class is specifically for Aperture Spectral Extraction

    Parameters are read in through Report.__init__

    """
    def _scalar(self):
        Report._scalar(self)
        if isinstance(self.signal.current_instrument, HSTInstrument) and 'extraction_width' in self.extracted and 'extraction_height' in self.extracted:
            self.r['scalar']['extraction_width'] = self.extracted['extraction_width']
            self.r['scalar']['extraction_height'] = self.extracted['extraction_height']

class SpecSegmentedPhotReport(SpecApPhotReport):
    pass

class SpecScanApPhotReport(SpecApPhotReport):
    """
    This report class is specifically for Aperture Spectral Scan Extraction

    Parameters are read in through Report.__init__
    """

class MSAFullApPhotReport(SpecApPhotReport):
    """
    This report class is specifically for MSA Full Aperture Spectroscopy

    Parameters are read in through Report.__init__

    """
    pass


class MSAShutterApPhotReport(Report):
    """
    This report class is specifically for MSA Shutter Aperture Photometry 

    Parameters are read in through Report.__init__

    """
    pass


class MSAApPhotReport(Report):
    """
    This report class is specifically for MSA Shutter Aperture Photometry 
    
    Parameters are read in through Report.__init__

    """
    pass


class SOSSReport(SpecApPhotReport):
    """
    This report class is specifically for SOSS Parameters are read in through Report.__init__

    """
    pass


class IFUApPhotReport(Report):
    """
    This report class is specifically for IFU Aperture Photometry (and its derivatives) Parameters are read in
    through Report.__init__

    """

    def _information(self):
        """
        IFU Aperture Photometry-specific information.

        IFU modes are computed as spectroscopy, but reported as imaging (reconstructed data cubes)
        """
        Report._information(self)
        # this is used by the UI to decide how to make the plots. even though IFUs are spectroscopic,
        # they're plotted as 2D spatial images. they could be shown as either single planes from the
        # reconstructed cubes or some combination of multiple planes.
        self.r['information']['calc_type'] = "image"

    def _projection(self):
        """
        IFU Aperture Photometry-specific projection.

        IFU modes use extracted properties for their projection, and also require wavelength setting in a
        fashion similar to Aperture Spectral Extraction.
        """
        self.grid = self.signal.grid
        # for IFUs we want the reconstructed image plane that's build by the strategy
        self.pix_grid = self.extracted['plane_grid']
        self.wave_pix = self.extracted['wavelength']

    def _2d(self):
        """
        IFU Aperture Photometry-specific 2D products.

        For IFU modes, all of the 2d outputs of IFUs are linked to the 3D cubes and defined there.
        """
        pass


    def _3d(self):
        """
        IFU Aperture Photometry-specific 3D and 2D products.

        IFU modes report 3D data cubes, in addition to the input model cubes all other strategies report. The
        2D IFU data is taken from slices of these 3D cubes.
        """
        Report._3d(self)

        # adding a np.random.randn() instance here operating on self.extracted['detector_signal'], before it's run for
        # cube_sim, will get the same random numbers as the v1.2 and earlier version of Report.py
        # Signal and noise. Get from extracted products

        self.cube_signal, self.cube_noise, self.cube_saturation, self.cube_group_map, self.cube_plane_grid = self.extracted['reconstructed']
        self.cube_sim = self.cube_signal + self.cube_noise * np.random.randn(*self.cube_noise.shape)

        self.r['3d']['reconstructed'] = self.cube_sim
        self.r['3d']['reconstructed_signal'] = self.cube_signal
        self.r['3d']['reconstructed_noise'] = self.cube_noise
        self.r['3d']['reconstructed_saturation'] = self.cube_saturation
        self.r['3d']['reconstructed_ngroups_map'] = self.cube_group_map
        self.r['3d']['reconstructed_snr'] = self.cube_signal / self.cube_noise

        # use cube planes for IFUs, though this may be temporary. the actual detector image for an IFU
        # observation is a set of spectra, one for each IFU slice. Populate the detector and SNR images from the
        # planes of the reconstructed cubes, but collapse the saturation cube to show the most severe saturation
        # for each spatial pixel.
        # These 2D plots do not obey resels, they are the actual measured planes.

        self.r['2d']['detector'] = self.signal.the_detector.to_resels_scalar(self.wave_pix, self.cube_sim, self.rw)
        self.r['2d']['snr'] = self.signal.the_detector.to_resels_scalar(self.wave_pix, self.cube_signal, self.rw) / self.signal.the_detector.to_resels_scalar(self.wave_pix, self.cube_noise, self.rw)
        self.r['2d']['saturation'] = np.amax(self.cube_saturation, axis=0)
        self.r['2d']['ngroups_map'] = np.amin(self.cube_group_map, axis=0)
        # This is the background rate in each pixel without sources
        self.bg_pix = self.signal.rate_plus_bg - self.signal.rate

    def _3dfits(self):
        """
        IFU Aperture Photometry-specific 3D fits output.

        IFU modes report 3D data cubes, in addition to the input model cubes all other strategies report.
        """
        Report._3dfits(self)
        header = self.pix_grid.wcs_info()
        t = self.signal.spectral_detector_transform()
        header['ctype3'] = 'Wavelength'
        header['crpix3'] = 1
        header['crval3'] = t['wave_det_min'] - 0.5 * t['wave_det_step']
        header['cdelt3'] = t['wave_det_step']
        header['cunit3'] = 'um'
        header['cname3'] = 'Wavelength'
        for k in ['', '_signal', '_noise', '_snr', '_saturation']:
            key = 'reconstructed' + k
            # the reconstructed cubes are in the proper z, y, x order...
            # The Y axis must be flipped so the result looks the same in DS9 (etc) as
            #  it does in the client.
            o = fits.PrimaryHDU(self.r['3d'][key][:, ::-1, :])
            o.header.update(header)
            self.f['3d'][key] = o

    def _warnings(self):
        """
        Collect warnings for the IFU mode, and generate saturation warnings. This does not call to
        Report._warnings() because ALL of the saturation warnings for IFU modes are different from anywhere
        else.

        Parameters
        -----------
        sub_words : dict
            dict of strings to use when differentiating warning subreports
        """
        sub_words = {'': None, '1': 'Nod 1', '2': 'Nod 2'}
        self.r['warnings'] = self.warnings
        sat_obj = self.cube_saturation[self.wave_index]

        self.r['scalar']['n_partial_saturated'] = (sat_obj == 1).sum()
        self.r['scalar']['n_full_saturated'] = (sat_obj == 2).sum()

        self.r['scalar']['n_cube_partial_saturated'] = (self.cube_saturation == 1).sum()
        self.r['scalar']['n_cube_full_saturated'] = (self.cube_saturation == 2).sum()

        self._check_output_warnings(self.signal.current_instrument.warning_list_output, sub_word=sub_words[self.sub_str], sub_str=self.sub_str)

        # these properties were only needed for warning generation
        del self.r["scalar"]["n_partial_saturated"]
        del self.r["scalar"]["n_full_saturated"]
        del self.r['scalar']['n_cube_partial_saturated']
        del self.r['scalar']['n_cube_full_saturated']

class IFUNodInSceneReport(IFUApPhotReport):
    """
    This report class is specifically for IFUNodInScene (and its derivatives) 
    
    Parameters are read in through Report.__init__

    """
    pass

class IFUNodOffSceneReport(IFUNodInSceneReport):
    """
    This report class is specifically for IFUNodOffScene (and its derivatives) 
    
    Parameters are read in through Report.__init__

    """
    pass


class ImagingApPhotReport(Report):
    """
    This report class is specifically for Imaging Aperture Photometry (and its derivatives) 
    
    Parameters are read in through Report.__init__

    """
    pass


class ImagingScanApPhot(ImagingApPhotReport):
    """
    This report class is specifically for Imaging Scan Aperture Photometry (and its derivatives) 
    
    Parameters are read in through Report.__init__

    """
    pass

class TARectPhotReport(Report):
    """
    This report class is specifically for Target Acquisition Photocenter (and its derivatives) 
    
    Parameters are read in through Report.__init__

    """
    def _warnings(self):
        """
        TAPhot-specific warnings.

        Target Acquisition modes are uniquely concerned with saturation, because target acquisition is not
        possible if too many pixels on the detector are saturated. Likewise, the SNR must be higher than a
        certain value for target acquisition to succeed.
        """
        if hasattr(self.signal.current_instrument, "max_saturated_pixels"):
            self.r["scalar"]["n_total_saturated"] = self.extracted['saturation_products']['full'][0] + self.extracted['saturation_products']['partial'][0]
            self.r["scalar"]["max_saturated_pixels"] = self.signal.current_instrument.max_saturated_pixels
            self.r["scalar"]["min_snr_threshold"] = self.signal.current_instrument.min_snr_threshold
        Report._warnings(self)
        if hasattr(self.signal.current_instrument, "max_saturated_pixels"):
            del self.r["scalar"]["n_total_saturated"]
            del self.r["scalar"]["max_saturated_pixels"]
            del self.r["scalar"]["min_snr_threshold"]


class TACentroidReport(TARectPhotReport):
    """
    This report class is specifically for Target Acquisition Centroid Parameters are read in through
    Report.__init__

    """
    def _1d(self):
        """
        TACentroid-specific 1D products.

        Some strategies may not return a flux. TargetAcqCentroid is one example for now; it reports the
        centroid in place of the flux. The property is still called "extracted_flux" in the curves, but we
        rename them here to avoid confusion. The "extracted_noise" is still the standard deviation of the
        extracted product.
        """
        Report._1d(self)

        self.r['scalar']['extracted_centroid'] = float(self.extracted['extracted_flux'][self.wave_index])
        self.r['scalar']['extracted_flux'] = np.nan


class TASpecReport(ImagingApPhotReport):
    """
    This report class is specifically for Spectral Target Acqusition (and its derivatives) 
    
    Parameters are read in through Report.__init__

    """
    def _1d(self):
        Report._1d(self)

        if self.sub_str == "":
            if "acq_mode" in self.input["strategy"] and self.input["strategy"]["acq_mode"] == "acq_peakxd":
                if "stripe" in self.input["strategy"]:
                    report_mapping = {"a": 0, "b": 1, "c": 2}
                    subreport = report_mapping[self.input["strategy"]["stripe"]]
                    self.r['scalar']['sn_per_stripe'] = {}
                    self.r['scalar']['time_per_stripe'] = {}
                    self.r['scalar']['reference_wavelength_stripe'] = {}
                    # flip the mapping list so we can generate stripe names from report items. This should be more robust against a different number of sub_reports.
                    mapping_report = {v: k for k, v in report_mapping.items()}
                    for subreport in range(len(self.extracts)):
                        signal = self.signal.the_detector.to_resels_scalar(self.wave_pix, self.extracts[subreport]['extracted_flux'], self.rw)
                        noise = self.signal.the_detector.to_resels_scalar(self.wave_pix, self.extracts[subreport]['extracted_noise'], self.rw)
                        self.r['scalar'][f'sn_per_stripe'][mapping_report[subreport]] =  signal/noise
                        self.r['scalar'][f'time_per_stripe'][mapping_report[subreport]] = self.extracts[subreport]['all_dithers_time']
                        self.r['scalar'][f'reference_wavelength_stripe'][mapping_report[subreport]] = self.extracts[subreport]['wavelength']
            elif self.input["configuration"]["instrument"]["mode"] == "fuv_specacq":
                mapping_report = {0: "fuv-b", 1: "fuv-a", 2: "combined"}
                if len(self.extracts) == 2: #in the case of only two subreports (G140L), the first is fuv-a
                    mapping_report = {0: "fuv-a" , 1: "combined"}
                self.r['scalar']['sn_per_stripe'] = {}
                self.r['scalar']['time_per_stripe'] = {}
                self.r['scalar']['reference_wavelength_stripe'] = {}
                for subreport in range(len(self.extracts)):
                    signal = self.signal.the_detector.to_resels_scalar(self.wave_pix, self.extracts[subreport]['extracted_flux'], self.rw)
                    noise = self.signal.the_detector.to_resels_scalar(self.wave_pix, self.extracts[subreport]['extracted_noise'], self.rw)
                    self.r['scalar'][f'sn_per_stripe'][mapping_report[subreport]] =  signal/noise
                    self.r['scalar'][f'time_per_stripe'][mapping_report[subreport]] = self.extracts[subreport]['all_dithers_time']
                    self.r['scalar'][f'reference_wavelength_stripe'][mapping_report[subreport]] = self.extracts[subreport]['wavelength']

    def _bop(self):
        """
        Compute HST's Bright Object Protection (Health and Safety) quantities
        """
        if self.sub_str == "":
            if "acq_mode" in self.input["strategy"] and self.input["strategy"]["acq_mode"] != "acq_peakxd" or "stripe" not in self.input["strategy"]:
                Report._bop(self)
            else:
                if "stripe" in self.input["strategy"]:
                    report_mapping = {0: "nuv-a", 1: "nuv-b", 2: "nuv-c"} # this maps stripe names to the subreport we should read them from

                    brightest_pixel_rate = {}
                    detector_total_rate = {}
                    bright_pixel_rates = []
                    for stripe in range(len(self.r["sub_reports"])):
                        bright_pixel_rates.append(self.r["sub_reports"][stripe]["bop"]["brightest_pixel_rate"][report_mapping[stripe]][0])
                        brightest_pixel_rate[stripe] = self.r["sub_reports"][stripe]["bop"]["brightest_pixel_rate"][report_mapping[stripe]]
                        detector_total_rate[stripe] = self.r["sub_reports"][stripe]["bop"]["detector_total_rate"][report_mapping[stripe]]
                    self.r['bop']['brightest_pixel_rate'] = brightest_pixel_rate
                    self.r['bop']['detector_total_rate'] = detector_total_rate

                    # TODO  -  JETC-2484
                    # This is a temporary bail-out code that prevents bop quantities to be calculated
                    # when the flux cube contains nans. Otherwise, the code downstream that expects
                    # bop values, crashes. To bem removed once the ticket above gets implemented.
                    if len(self.signal.brightest_pixel_rate.values()) == 0:
                        return

                    # BOP brightest pixel rate and detector total rate are provided for more than one segments
                    # find the highest per-segment value and add BOP warning based on it

                    # max brightest pixel rate from the dict with tuples for rate and wavelength at which it occurs for every chip
                    time = self.signal.the_detector.exposure_spec.exposure_time # for a single exposure, taking nsplit into account
                    max_stripe = np.argmax(bright_pixel_rates)
                    time = self.r["sub_reports"][max_stripe]["scalar"]["exposure_time"]

                    self.r['bop']['max_bright_pixel_rate'] = np.max(bright_pixel_rates)
                    self.r['bop']['max_bright_pixel_count'] = np.max(bright_pixel_rates) * time
                    self.r['bop']['max_total_detector_rate'] = max(self.r['bop']['detector_total_rate'].values())
        else:
            Report._bop(self)


class TAPhotReport(TARectPhotReport):
    pass

class TABoxPhotReport(TARectPhotReport):
    pass
