"""
Stack DICOM datasets into volumes. The contents of this module are imported
into the package namespace.
"""
import warnings, re, traceback
from copy import deepcopy

try:
    from collections import OrderedDict
except ImportError:
    from ordereddict import OrderedDict

from .nifti_wrapper import NiftiWrapper
import numpy as np
import pydicom
from pydicom.datadict import tag_for_keyword
import nibabel as nb
from nibabel.nifti1 import Nifti1Extensions
from nibabel.spatialimages import HeaderDataError
from nibabel.orientations import (io_orientation,
                                  apply_orientation,
                                  inv_ornt_aff,
                                  ornt_transform,
                                  axcodes2ornt)
with warnings.catch_warnings():
    warnings.simplefilter('ignore')
    from nibabel.nicom.dicomwrappers import wrapper_from_data

from .globals import SORT_GUESSES
from . import snd
from .dcmmeta import DcmMetaExtension


def make_key_regex_filter(exclude_res, force_include_res=None):
    '''Make a meta data filter using regular expressions.

    Parameters
    ----------
    exclude_res : sequence
        Sequence of regular expression strings. Any meta data where the key
        matches one of these expressions will be excluded, unless it matches
        one of the `force_include_res`.
    force_include_res : sequence
        Sequence of regular expression strings. Any meta data where the key
        matches one of these expressions will be included.

    Returns
    -------
    A callable which can be passed to `DicomStack` as the `meta_filter`.
    '''
    exclude_re = re.compile('|'.join(['(?:' + regex + ')'
                                      for regex in exclude_res])
                           )
    include_re = None
    if force_include_res:
        include_re = re.compile('|'.join(['(?:' + regex + ')'
                                          for regex in force_include_res])
                               )

    def key_regex_filter(key, value):
        return (exclude_re.search(key) and
                not (include_re and include_re.search(key)))
    return key_regex_filter


default_key_excl_res = ['Patient',
                        'Physician',
                        'Operator',
                        'Date',
                        'Birth',
                        'Address',
                        'Institution',
                        'Station',
                        'SiteName',
                        'Age',
                        'Comment',
                        'Phone',
                        'Telephone',
                        'Insurance',
                        'Religious',
                        'Language',
                        'Military',
                        'MedicalRecord',
                        'Ethnic',
                        'Occupation',
                        'Unknown',
                        'PrivateTagData',
                        'UID',
                        'StudyDescription',
                        'DeviceSerialNumber',
                        'ReferencedImageSequence',
                        'RequestedProcedureDescription',
                        'PerformedProcedureStepDescription',
                        'PerformedProcedureStepID',
                       ]
'''A list of regexes passed to `make_key_regex_filter` as `exclude_res` to
create the `default_meta_filter`.'''


default_key_incl_res = ['ImageOrientationPatient',
                        'ImagePositionPatient',
                       ]
'''A list of regexes passed to `make_key_regex_filter` as `force_include_res`
to create the `default_meta_filter`.'''


default_meta_filter = make_key_regex_filter(default_key_excl_res,
                                            default_key_incl_res)
'''Default meta_filter for `DicomStack`.'''


def reorder_voxels(vox_array, affine, voxel_order):
    '''Reorder the given voxel array and corresponding affine.

    Parameters
    ----------
    vox_array : array
        The array of voxel data

    affine : array
        The affine for mapping voxel indices to Nifti patient space

    voxel_order : str
        A three character code specifing the desired ending point for rows,
        columns, and slices in terms of the orthogonal axes of patient space:
        (l)eft, (r)ight, (a)nterior, (p)osterior, (s)uperior, and (i)nferior.

    Returns
    -------
    out_vox : array
        An updated view of vox_array.

    out_aff : array
        A new array with the updated affine

    reorient_transform : array
        The transform used to update the affine.

    ornt_trans : tuple
        The orientation transform used to update the orientation.

    '''
    #Check if voxel_order is valid
    voxel_order = voxel_order.upper()
    if len(voxel_order) != 3:
        raise ValueError('The voxel_order must contain three characters')
    dcm_axes = ['LR', 'AP', 'SI']
    for char in voxel_order:
        if not char in 'LRAPSI':
            raise ValueError('The characters in voxel_order must be one '
                             'of: L,R,A,P,I,S')
        for idx, axis in enumerate(dcm_axes):
            if char in axis:
                del dcm_axes[idx]
    if len(dcm_axes) != 0:
        raise ValueError('No character in voxel_order corresponding to '
                         'axes: %s' % dcm_axes)

    #Check the vox_array and affine have correct shape/size
    if len(vox_array.shape) < 3:
        raise ValueError('The vox_array must be at least three dimensional')
    if affine.shape != (4, 4):
        raise ValueError('The affine must be 4x4')

    #Pull the current index directions from the affine
    orig_ornt = io_orientation(affine)
    new_ornt = axcodes2ornt(voxel_order)
    ornt_trans = ornt_transform(orig_ornt, new_ornt)
    orig_shape = vox_array.shape
    vox_array = apply_orientation(vox_array, ornt_trans)
    aff_trans = inv_ornt_aff(ornt_trans, orig_shape)
    affine = np.dot(affine, aff_trans)

    return (vox_array, affine, aff_trans, ornt_trans)


class IncongruentImageError(Exception):
    def __init__(self, msg):
        '''An exception denoting that a DICOM with incorrect size or orientation
        was passed to `DicomStack.add_dcm`.'''
        self.msg = msg

    def __str__(self):
        return "The image is not congruent to the existing stack: %s" % self.msg


class ImageCollisionError(Exception):
    '''An exception denoting that a DICOM which collides with one already in
    the stack was passed to a `DicomStack.add_dcm`.'''
    def __str__(self):
        return "The image collides with one already in the stack"


class NonImageDataSetError(Exception):
    '''The DICOM doesn't contain image data'''
    def __str__(self):
        return "The DICOM doesn't contain an image"


class DicomConversionError(Exception):
    def __str__(self):
        return "Unable to convert DICOM"
    

class InvalidStackError(Exception):
    def __init__(self, msg):
        '''An exception denoting that a `DicomStack` is not currently valid'''
        self.msg = msg

    def __str__(self):
        return "The DICOM stack is not valid: %s" % self.msg


class DicomOrdering(object):
    '''Object defining an ordering for a set of dicom datasets. Create a
    DicomOrdering with the given DICOM element keyword.

    Parameters
    ----------
    key : str
        The DICOM keyword to use for ordering the datasets

    abs_ordering : sequence
        A sequence specifying the absolute order for values corresponding
        to the `key`. Instead of ordering by the value associated with the
        `key`, the index of the value in this sequence will be used.

    abs_as_str : bool
        If true, the values will be converted to strings before looking up
        the index in `abs_ordering`.
    '''

    def __init__(self, key, abs_ordering=None, abs_as_str=False):
        self.key = key
        self.abs_ordering = abs_ordering
        self.abs_as_str = abs_as_str

    def get_ordinate(self, nw):
        '''Get the ordinate for the given DICOM data set.

        Parameters
        ----------
        ds : dict like
            The DICOM data set we want the ordinate of. Should allow
            dict like access where DICOM keywords return the corresponing
            value.

        Returns
        -------
        An ordinate for the data set. If `abs_ordering` is None then this will
        just be the value for the keyword `key`. Otherwise it will be an
        integer.
        '''
        val = nw.get_meta(self.key)
        if self.abs_ordering:
            if self.abs_as_str:
                val = str(val)
            return self.abs_ordering.index(val)
        return val


DEFAULT_GROUP_KEYS =  ('SeriesInstanceUID',
                       'SeriesNumber',
                       'ProtocolName',
                       'ImageOrientationPatient')
'''Default keys for grouping DICOM files that belong in the same
multi-dimensional array together.'''


DEFAULT_CLOSE_KEYS = ('ImageOrientationPatient',)
'''Default keys needing np.allclose instead of equaulity testing when grouping 
'''


_pix_tags = tuple(tag_for_keyword(x) for x in ('PixelData', 'FloatPixelData', 'DoubleFloatPixelData'))


def is_image(dcm):
    '''Test if the data set is an image'''
    tags = set(dcm.keys())
    if any(t in tags for t in _pix_tags):
        return True
    return False


class DicomStack(object):
    '''Defines a method for stacking together DICOM data sets into a multi
    dimensional volume.

    Tailored towards creating NiftiImage output, but can also just create numpy
    arrays. Can summarize all of the meta data from the input DICOM data sets
    into a Nifti header extension (see `dcmmeta.DcmMetaExtension`).

    Parameters
    ----------
    time_order : str or DicomOrdering
        The DICOM keyword or DicomOrdering object specifying how to order
        the DICOM data sets along the time dimension.

    vector_order : str or DicomOrdering
        The DICOM keyword or DicomOrdering object specifying how to order
        the DICOM data sets along the vector dimension.

    meta_filter : callable
        A callable that takes a meta data key and value, and returns True if
        that meta data element should be excluded from the DcmMeta extension.

    Notes
    -----
    If both time_order and vector_order are None, the time_order will be
    guessed based off the data sets.
    '''
    OTHER_REQ_ATTRS = (
        'Rows',
        'Columns',
        'PixelSpacing',
        'ImageOrientationPatient',
        'InPlanePhaseEncodingDirection',
        'BitsStored',
        'SharedFunctionalGroupsSequence',
        'PerFrameFunctionalGroupsSequence',
    )

    SORT_GUESSES = SORT_GUESSES[:]

    def __init__(self, time_order=None, vector_order=None, meta_filter=None):
        if isinstance(time_order, str):
            self._time_order = DicomOrdering(time_order)
        else:
            self._time_order = time_order
        if isinstance(vector_order, str):
            self._vector_order = DicomOrdering(vector_order)
        else:
            self._vector_order = vector_order
        if meta_filter is None:
            self._meta_filter = default_meta_filter
        else:
            self._meta_filter = meta_filter
        #Sets all the state variables to their defaults
        self.clear()

    @classmethod
    def get_min_req_meta(cls):
        """Returns `(min_dicom, min_trans)`, the min required DICOM keys and translators 
        """
        min_keys = snd.get_snd_sources()
        for key in DEFAULT_GROUP_KEYS + cls.SORT_GUESSES + cls.OTHER_REQ_ATTRS:
            if key.startswith("SND."):
                continue
            min_keys.add(key)
        min_dicom = []
        min_trans = set()
        for key in min_keys:
            toks = key.split(".")
            if len(toks) == 1:
                min_dicom.append(key)
            else:
                min_trans.add(toks[0])
        return min_dicom, min_trans

    def _chk_congruent(self, in_wrp):
        if not self._ref_input is None:
            if self._ref_input.nii_img.shape != in_wrp.nii_img.shape:
                raise IncongruentImageError("Mismatch in data array shapes")
            if not np.allclose(
                self._ref_input.nii_img.affine[:3, :3], 
                in_wrp.nii_img.affine[:3, :3], 
                atol=5e-5,
            ):
                raise IncongruentImageError("Mismatch in affine transforms")

    def add_dcm(self, dcm, meta=None):
        '''Add a pydicom dataset to the stack.

        Parameters
        ----------
        dcm : pydicom.dataset.Dataset
            The dataset being added to the stack

        meta : dict
            The extracted meta data for the DICOM data set `dcm`. If None just the 
            minimal needed meta data will be extracted

        Raises
        ------
        IncongruentImageError
            The provided `dcm` does not match the orientation or dimensions of
            those already in the stack.

        ImageCollisionError
            The provided `dcm` has the same slice location and time/vector
            values.
        
        NonImageDataSetError
            The provided `dcm` doesn't contain image data
        '''
        if not is_image(dcm):
            raise NonImageDataSetError()
        # Extract at least some minimal meta data
        if meta is None:
            from .extract import EXTRACTORS, ExtractionLevel
            meta = EXTRACTORS[ExtractionLevel.MINIMAL](dcm)
        # Covert to DicomWrapper and NiftiWrapper
        dw = wrapper_from_data(dcm)
        nii_wrp = NiftiWrapper.from_dicom_wrapper(dw, meta)
        # TODO: Look into if / why this check is needed?
        if nii_wrp is None:
            raise DicomConversionError()
        # Inject our slightly normalized DICOM meta data
        snd.inject(nii_wrp.meta_ext)
        # Sanity check against existing inputs, then pull some data we will need later
        self._chk_congruent(nii_wrp)
        slp, inter = nii_wrp.nii_img.header.get_slope_inter()
        slp = 1 if slp is None else slp
        inter = 0 if inter is None else inter
        self._slope_inter.add((slp, inter))
        self._phase_enc_dirs.add(nii_wrp.get_meta('InPlanePhaseEncodingDirection'))
        self._repetition_times.add(nii_wrp.get_meta('RepetitionTime'))
        # Pull the info used for sorting if the keys to use are already specified
        slice_pos = dw.slice_indicator
        self._slice_pos_vals.add(slice_pos)
        time_val = None
        if self._time_order:
            time_val = self._time_order.get_ordinate(nii_wrp)
        self._time_vals.add(time_val)
        vector_val = None
        if self._vector_order:
            vector_val = self._vector_order.get_ordinate(nii_wrp)
        self._vector_vals.add(vector_val)
        sorting_tuple = (vector_val, time_val, slice_pos)
        # Also check for collisions if we already know how to sort
        if ((not self._time_order is None or
             not self._vector_order is None) and
            sorting_tuple in self._sorting_tuples
           ):
            raise ImageCollisionError()
        # Add image to our internal set
        self._sorting_tuples.add(sorting_tuple)
        self._files_info.append((nii_wrp, sorting_tuple))
        if self._ref_input is None:
            self._ref_input = nii_wrp
        #Set the dirty flags
        self._shape_dirty = True
        self._meta_dirty = True

    def clear(self):
        '''Remove any DICOM datasets from the stack.'''
        self._slice_pos_vals = set()
        self._time_vals = set()
        self._vector_vals = set()
        self._sorting_tuples = set()
        self._phase_enc_dirs = set()
        self._repetition_times = set()
        self._slope_inter = set()
        self._ref_input = None
        self._shape_dirty = True
        self._shape = None
        self._meta_dirty = True
        self._meta = None
        self._files_info = []

    def _chk_order(self, slice_positions, files_per_vol, num_volumes,
                   num_time_points, num_vec_comps):
        #Sort the files
        self._files_info.sort(key=lambda x: x[1])
        if files_per_vol > 1:
            for vol_idx in range(num_volumes):
                start_slice = vol_idx * files_per_vol
                end_slice = start_slice + files_per_vol
                self._files_info[start_slice:end_slice] = \
                    sorted(self._files_info[start_slice:end_slice],
                           key=lambda x: x[1][-1])
        #Do a thorough check for correctness
        for vec_idx in range(num_vec_comps):
            file_idx = vec_idx*num_time_points*files_per_vol
            curr_vec_val = self._files_info[file_idx][1][0]
            for time_idx in range(num_time_points):
                for slice_idx in range(files_per_vol):
                    file_idx = (vec_idx*num_time_points*files_per_vol +
                                time_idx*files_per_vol + slice_idx)
                    file_info = self._files_info[file_idx]
                    if file_info[1][0] != curr_vec_val:
                        raise InvalidStackError("Not enough images with the " +
                                                "vector value of " +
                                                str(curr_vec_val))
                    if (file_info[1][2] != slice_positions[slice_idx]):
                        if (file_info[1][2] == slice_positions[slice_idx-1]):
                            error_msg = ["Duplicate slice position"]
                        else:
                            error_msg = ["Missing slice position"]
                        error_msg.append(" at slice index %d" % slice_idx)
                        if num_time_points > 1:
                            error_msg.append(' in time point %d' % time_idx)
                        if num_vec_comps > 1:
                            error_msg.append(' for vector component %s' %
                                             str(curr_vec_val))
                        raise InvalidStackError(''.join(error_msg))

    def get_shape(self):
        '''Get the shape of the stack.

        Returns
        -------
        A tuple of integers giving the size of the dimensions of the stack.

        Raises
        ------
        InvalidStackError
            The stack is incomplete or invalid.
        '''
        #If the dirty flag is not set, return the cached value
        if not self._shape_dirty:
            return self._shape
        self._guessed_order = None
        #We need at least one file in the stack
        n_files = len(self._files_info)
        if n_files == 0:
            raise InvalidStackError("No files in the stack")
        #Figure out number of files and slices per volume
        files_per_vol = len(self._slice_pos_vals)
        slice_positions = sorted(list(self._slice_pos_vals))
        #If more than one file per volume, check that slice spacing is equal
        if files_per_vol > 1:
            spacings = []
            for idx in range(files_per_vol - 1):
                spacings.append(slice_positions[idx+1] - slice_positions[idx])
            spacings = np.array(spacings)
            avg_spacing = np.mean(spacings)
            if not np.allclose(avg_spacing, spacings, rtol=4e-2):
                raise InvalidStackError("Slice spacings are not consistent")
        #Simple check for an incomplete stack
        if n_files % files_per_vol != 0:
            raise InvalidStackError("Number of files is not an even multiple "
                                    "of the number of unique slice positions.")
        num_volumes = n_files // files_per_vol
        #Figure out the number of vector components and time points
        num_vec_comps = len(self._vector_vals)
        if num_vec_comps > num_volumes:
            raise InvalidStackError("Vector variable varies within volumes")
        if num_volumes % num_vec_comps != 0:
            raise InvalidStackError("Number of volumes not an even multiple "
                                    "of the number of vector components.")
        num_time_points = num_volumes // num_vec_comps
        #If both sort keys are None try to guess
        if (num_volumes > 1 and self._time_order is None and
                self._vector_order is None):
            #Get a list of possible sort orders
            possible_orders = []
            for key in self.SORT_GUESSES:
                vals = set([file_info[0].get_meta(key)
                            for file_info in self._files_info]
                          )
                if any(v is None for v in vals):
                    continue
                if len(vals) == num_volumes or len(vals) == n_files:
                    possible_orders.append(key)
            if len(possible_orders) == 0:
                raise InvalidStackError("Unable to guess key for sorting the "
                                        "fourth dimension")
            #Try out each possible sort order
            for time_order in possible_orders:
                #Update sorting tuples
                for idx in range(n_files):
                    nii_wrp, curr_tuple = self._files_info[idx]
                    self._files_info[idx] = (nii_wrp,
                                             (curr_tuple[0],
                                              nii_wrp[time_order],
                                              curr_tuple[2]
                                             )
                                            )
                #Check the order
                try:
                    self._chk_order(slice_positions,
                                    files_per_vol,
                                    num_volumes,
                                    num_time_points,
                                    num_vec_comps)
                except InvalidStackError:
                    pass
                else:
                    self._guessed_order = DicomOrdering(time_order)
                    break
            else:
                raise InvalidStackError("Unable to guess key for sorting the "
                                        "fourth dimension")
        else: 
            # If at least one sort key was specified, just check the order
            self._chk_order(slice_positions,
                            files_per_vol,
                            num_volumes,
                            num_time_points,
                            num_vec_comps)
        # Stack appears to be valid, build the shape tuple
        file_shape = self._files_info[0][0].nii_img.shape
        vol_shape = list(file_shape)
        if files_per_vol > 1:
            vol_shape[2] = files_per_vol
        shape = vol_shape+ [num_time_points, num_vec_comps]
        if shape[4] == 1:
            shape = shape[:-1]
            if shape[3] == 1:
                shape = shape[:-1]
        self._shape = tuple(shape)

        self._shape_dirty = False
        return self._shape

    shape = property(fget=get_shape)

    def get_data(self, scaled=True):
        '''Get an array of the voxel values.

        Parameters
        ----------
        scaled
            Set to False to get unscaled data

        Returns
        -------
        A numpy array filled with values from the DICOM data sets' pixels.

        Raises
        ------
        InvalidStackError
            The stack is incomplete or invalid.
        '''
        if len(self._files_info) == 0:
            raise InvalidStackError("No valid files in the stack")
        # Determine output dtype
        stack_dtype = self._files_info[0][0].nii_img.dataobj.dtype
        bits_stored = self._files_info[0][0].get_meta('BitsStored', default=16)
        out_dtype = stack_dtype
        if scaled:
            if len(self._slope_inter) > 1 or list(self._slope_inter)[0] != (1.0, 0.0):
                out_dtype = np.float32
            else:
                scaled = False
        # This is a hack to keep fslview happy, it does not like unsigned short
        # data. If less than 16 bits are being used for each pixel we default 
        # to signed short.        
        if out_dtype == np.uint16 and bits_stored < 16:
            out_dtype = np.int16
        # Optimization for single file
        if len(self._files_info) == 1:
            vox_array = np.asarray(
                self._files_info[0][0].nii_img.dataobj, dtype=out_dtype
            )
            if scaled:
                slope, inter = list(self._slope_inter)[0]
                vox_array *= slope
                vox_array += inter
            return vox_array
        # Allocate output array
        stack_shape = self.shape
        stack_shape = tuple(list(stack_shape) + ((5 - len(stack_shape)) * [1]))
        vox_array = np.empty(stack_shape, dtype=out_dtype)
        # Fill the array with data
        n_vols = 1
        if len(stack_shape) > 3:
            n_vols *= stack_shape[3]
        if len(stack_shape) > 4:
            n_vols *= stack_shape[4]
        files_per_vol = len(self._files_info) // n_vols
        file_shape = self._files_info[0][0].nii_img.shape
        for vec_idx in range(stack_shape[4]):
            for time_idx in range(stack_shape[3]):
                if files_per_vol == 1 and file_shape[2] != 1:
                    file_idx = vec_idx*(stack_shape[3]) + time_idx
                    vox_array[:, :, :, time_idx, vec_idx] = \
                        np.asarray(self._files_info[file_idx][0].nii_img.dataobj)
                    if scaled:
                        slope, inter = self._files_info[file_idx][0].nii_img.header.get_slope_inter()
                        vox_array[:, :, :, time_idx, vec_idx] *= slope
                        vox_array[:, :, :, time_idx, vec_idx] += inter
                else:
                    for slice_idx in range(files_per_vol):
                        file_idx = (vec_idx*(stack_shape[3]*stack_shape[2]) +
                                    time_idx*(stack_shape[2]) + slice_idx)
                        vox_array[:, :, slice_idx, time_idx, vec_idx] = \
                             np.asarray(self._files_info[file_idx][0].nii_img.dataobj[:, :, 0])
                        if scaled:
                            slope, inter = self._files_info[file_idx][0].nii_img.header.get_slope_inter()
                            if slope is None:
                                slope = 1.0
                            if inter is None:
                                inter = 0.0
                            vox_array[:, :, slice_idx, time_idx, vec_idx] *= slope
                            vox_array[:, :, slice_idx, time_idx, vec_idx] += inter
        # Trim unused time/vector dimensions
        if stack_shape[4] == 1:
            vox_array = vox_array[...,0]
            if stack_shape[3] == 1:
                vox_array = vox_array[...,0]
        return vox_array

    data = property(fget=get_data)

    def get_affine(self):
        '''Get the affine transform for mapping row/column/slice indices
        to Nifti (RAS) patient space.

        Returns
        -------
        A 4x4 numpy array containing the affine transform.

        Raises
        ------
        InvalidStackError
            The stack is incomplete or invalid.
        '''
        #Figure out the number of three (or two) dimensional volumes
        shape = self.shape
        n_vols = 1
        if len(shape) > 3:
            n_vols *= shape[3]
        if len(shape) > 4:
            n_vols *= shape[4]

        #Figure out the number of files in each volume
        files_per_vol = len(self._files_info) // n_vols

        #Pull the DICOM Patient Space affine from the first input
        aff = self._files_info[0][0].nii_img.affine

        #If there is more than one file per volume, we need to fix slice scaling
        if files_per_vol > 1:
            first_offset = aff[:3, 3]
            second_offset = self._files_info[1][0].nii_img.affine[:3, 3]
            scaled_slc_dir = second_offset - first_offset
            aff[:3, 2] = scaled_slc_dir

        return aff

    affine = property(fget=get_affine)

    def to_nifti(self, voxel_order='LAS', embed_meta=False):
        '''Returns a NiftiImage with the data and affine from the stack.

        Parameters
        ----------
        voxel_order : str
            A three character string repsenting the voxel order in patient
            space (see the function `reorder_voxels`). Can be None or an empty
            string to disable reorientation.

        embed_meta : bool
            If true a dcmmeta.DcmMetaExtension will be embedded in the Nifti
            header.

        Returns
        -------
        A nibabel.nifti1.Nifti1Image created with the stack's data and affine.
        '''
        #Get the voxel data and affine
        pre_scaled = len(self._slope_inter) > 1
        data = self.get_data(scaled=pre_scaled)
        affine = self.affine

        #Figure out the number of three (or two) dimensional volumes
        n_vols = 1
        if len(data.shape) > 3:
            n_vols *= data.shape[3]
        if len(data.shape) > 4:
            n_vols *= data.shape[4]

        vols_per_file = n_vols // len(self._files_info)
        files_per_vol = len(self._files_info) // n_vols

        #Reorder the voxel data if requested
        permutation = [0, 1, 2]
        slice_dim = 2
        reorient_transform = np.eye(4)
        if voxel_order:
            (data,
             affine,
             reorient_transform,
             ornt_trans) = reorder_voxels(data, affine, voxel_order)
            permutation, flips = zip(*ornt_trans)
            permutation = [int(val) for val in permutation]

            #Reverse file order in each volume's files if we flipped slice order
            #This will keep the slice times and meta data order correct
            if files_per_vol > 1 and flips[slice_dim] == -1:
                self._shape_dirty = True
                for vol_idx in range(n_vols):
                    start = vol_idx * files_per_vol
                    stop = start + files_per_vol
                    self._files_info[start:stop] = [self._files_info[idx]
                                                    for idx in range(stop - 1,
                                                                      start - 1,
                                                                      -1)
                                                   ]

            #Update the slice dim
            slice_dim = permutation[2]

        #Create the nifti image using the data array
        nifti_image = nb.Nifti1Image(data, affine)
        nifti_header = nifti_image.header
        if not pre_scaled:
            slope, inter = list(self._slope_inter)[0]
            if (slope, inter) != (1.0, 0.0):
                nifti_header.set_slope_inter(slope, inter)

        #Set the units and dimension info
        nifti_header.set_xyzt_units('mm', 'msec')
        if len(self._repetition_times) == 1 and not None in self._repetition_times:
            nifti_header['pixdim'][4] = list(self._repetition_times)[0]
        dim_info = {'freq' : None, 'phase' : None, 'slice' : slice_dim}
        if len(self._phase_enc_dirs) == 1 and not None in self._phase_enc_dirs:
            phase_dir = list(self._phase_enc_dirs)[0]
            if phase_dir == 'ROW':
                dim_info['phase'] = permutation[1]
                dim_info['freq'] = permutation[0]
            else:
                dim_info['phase'] = permutation[0]
                dim_info['freq'] = permutation[1]
        nifti_header.set_dim_info(**dim_info)
        
        n_slices = data.shape[slice_dim]

        #Set the slice timing header info
        has_acq_time = (self._files_info[0][0].get_meta('SND.AcquisitionTimeStamp') !=
                        None)
        if files_per_vol > 1 and has_acq_time:
            #Pull out the relative slice times for the first volume
            slice_times = np.array([file_info[0]['SND.AcquisitionTimeStamp']
                                    for file_info in self._files_info[:n_slices]]
                                  )
            slice_times -= np.min(slice_times)

            #If there is more than one volume, check if times are consistent
            is_consistent = True
            for vol_idx in range(1, n_vols):
                start_slice = vol_idx * n_slices
                end_slice = start_slice + n_slices
                slices_info = self._files_info[start_slice:end_slice]
                vol_slc_times = \
                    np.array([file_info[0]['SND.AcquisitionTimeStamp']
                              for file_info in slices_info]
                            )
                vol_slc_times -= np.min(vol_slc_times)
                if not np.allclose(slice_times, vol_slc_times):
                    is_consistent = False
                    break

            #If the times are consistent and not all zero, try setting the slice
            #times (sets the slice duration and code if possible).
            if is_consistent and not np.allclose(slice_times, 0.0):
                try:
                    nifti_header.set_slice_times(slice_times)
                except HeaderDataError:
                    pass

        #Embed the meta data extension if requested
        if embed_meta:
            if len(self._files_info) > 1:
                #Build meta data for each volume if needed
                vol_meta = []
                if files_per_vol > 1:
                    for vol_idx in range(n_vols):
                        start_slice = vol_idx * n_slices
                        end_slice = start_slice + n_slices
                        exts = [file_info[0].meta_ext
                                for file_info in self._files_info[start_slice:end_slice]]
                        meta = DcmMetaExtension.from_sequence(exts, 2)
                        vol_meta.append(meta)
                else:
                    vol_meta = [file_info[0].meta_ext
                                for file_info in self._files_info]
                #Build meta data for each time point / vector component
                if len(data.shape) == 5:
                    if data.shape[3] != 1:
                        vec_meta = []
                        for vec_idx in range(data.shape[4]):
                            start_idx = vec_idx * data.shape[3]
                            end_idx = start_idx + data.shape[3]
                            meta = DcmMetaExtension.from_sequence(
                                vol_meta[start_idx:end_idx], 3
                            )
                            vec_meta.append(meta)
                    else:
                        vec_meta = vol_meta
                    meta_ext = DcmMetaExtension.from_sequence(vec_meta, 4)
                elif len(data.shape) == 4:
                    meta_ext = DcmMetaExtension.from_sequence(vol_meta, 3)
                else:
                    meta_ext = vol_meta[0]
                    # TODO(BUG): file_info is not available here!
                    if meta_ext is self._files_info[0][0].meta_ext:
                        meta_ext = deepcopy(meta_ext)
            else:
                meta_ext = self._files_info[0][0].meta_ext
               
            meta_ext.shape = data.shape
            meta_ext.slice_dim = slice_dim
            meta_ext.affine = nifti_header.get_best_affine()
            meta_ext.reorient_transform = reorient_transform
            # Track which attributes were used to sort extra-spatial dimensions
            extra_shape = meta_ext.shape[3:]
            if extra_shape:
                if extra_shape[0] != 1 and meta_ext.time_dim is None:
                    ord = self._time_order
                    if ord is None:
                        ord = self._guessed_order
                    if ord is not None:
                        meta_ext.time_dim = ord.key
                if len(extra_shape) == 2 and extra_shape[1] != 1:
                    ord = self._vector_order
                    if ord is not None:
                        meta_ext.vector_dim = ord.key
            #Filter and embed the meta data
            meta_ext.filter_meta(self._meta_filter)
            nifti_header.extensions = Nifti1Extensions([meta_ext])

        nifti_image.update_header()
        return nifti_image

    def to_nifti_wrapper(self, voxel_order=''):
        '''Convienance method. Calls `to_nifti` and returns a `NiftiWrapper`
        generated from the result.
        '''
        return NiftiWrapper(self.to_nifti(voxel_order, True))


def parse_and_group(src_paths, group_by=DEFAULT_GROUP_KEYS, extractor=None,
                    force=False, warn_on_except=False,
                    close_tests=DEFAULT_CLOSE_KEYS):
    '''Parse the given dicom files and group them together. Each group is
    stored as a (list) value in a dict where the key is a tuple of values
    corresponding to the keys in 'group_by'

    Parameters
    ----------
    src_paths : sequence
        A list of paths to the source DICOM files.

    group_by : tuple
        Meta data keys to group data sets with. Any data set with the same
        values for these keys will be grouped together. This tuple of values
        will also be the key in the result dictionary.

    extractor : callable
        Should take a pydicom.dataset.Dataset and return a dictionary of the
        extracted meta data.

    force : bool
        Force reading source files even if they do not appear to be DICOM.

    warn_on_except : bool
        Convert exceptions into warnings, possibly allowing some results to be
        returned.

    close_tests : sequence
        Any `group_by` key listed here is tested with `numpy.allclose` instead
        of straight equality when determining group membership.

    Returns
    -------
    groups : dict
        A dict mapping tuples of values (corresponding to 'group_by') to groups
        of data sets. Each element in the list is a tuple containing the dicom
        object, the parsed meta data, and the filename.
    '''
    if extractor is None:
        from .extract import EXTRACTORS, ExtractionLevel
        extractor = EXTRACTORS[ExtractionLevel.MINIMAL]

    results = {}
    close_elems = {}
    for dcm_path in src_paths:
        #Read the DICOM file
        try:
            dcm = pydicom.dcmread(dcm_path, force=force, defer_size=32)
        except Exception as e:
            if warn_on_except:
                warnings.warn('Error reading file %s: %s' % (dcm_path, str(e)))
                continue
            else:
                raise

        # Warn and skip non-image data sets
        if not is_image(dcm):
            warnings.warn("Skipping non-image data set: %s" % dcm_path)
            continue

        #Extract the meta data and group
        meta = extractor(dcm)
        key_list = [] # Values from group_by elems with equality testing
        close_list = [] # Values from group_by elems with np.allclose testing
        for grp_key in group_by:
            key_elem = meta.get(grp_key)
            if isinstance(key_elem, list) or isinstance(key_elem, pydicom.multival.MultiValue):
                key_elem = tuple(key_elem)
            if grp_key in close_tests:
                close_list.append(key_elem)
            else:
                key_list.append(key_elem)

        # Initially each key has multiple sub_results (corresponding to
        # different values of the "close" keys)
        key = tuple(key_list)
        if not key in results:
            results[key] = [(close_list, [(dcm, meta, dcm_path)])]
        else:
            # Look for a matching sub_result
            for c_list, sub_res in results[key]:
                for c_idx, c_val in enumerate(c_list):
                    if not (
                        (c_val is None and close_list[c_idx] is None) or
                        np.allclose(c_val, close_list[c_idx], atol=5e-5)
                    ):
                        break
                else:
                    sub_res.append((dcm, meta, dcm_path))
                    break
            else:
                # No match found, append another sub result
                results[key].append((close_list, [(dcm, meta, dcm_path)]))

    # Unpack sub results, using the canonical value for the close keys
    full_results = {}
    for eq_key, sub_res_list in results.items():
        for close_key, sub_res in sub_res_list:
            full_key = []
            eq_idx = 0
            close_idx = 0
            for grp_key in group_by:
                if grp_key in close_tests:
                    full_key.append(close_key[close_idx])
                    close_idx += 1
                else:
                    full_key.append(eq_key[eq_idx])
                    eq_idx += 1
            full_key = tuple(full_key)
            full_results[full_key] = sub_res

    return OrderedDict(sorted(full_results.items()))


def stack_group(group, warn_on_except=False, **stack_args):
    result = DicomStack(**stack_args)
    for dcm, meta, fn in group:
        try:
            result.add_dcm(dcm, meta)
        except Exception as e:
            if warn_on_except:
                tb_str = "\n".join(traceback.format_tb(e.__traceback__))
                warnings.warn('Error adding file %s to stack: %s\n%s' %
                              (fn, str(e), tb_str))
            else:
                raise
    return result


def parse_and_stack(src_paths, group_by=DEFAULT_GROUP_KEYS, extractor=None,
                    force=False, warn_on_except=False, **stack_args):
    '''Parse the given dicom files into a dictionary containing one or more
    DicomStack objects.

    Parameters
    ----------
    src_paths : sequence
        A list of paths to the source DICOM files.

    group_by : tuple
        Meta data keys to group data sets with. Any data set with the same
        values for these keys will be grouped together. This tuple of values
        will also be the key in the result dictionary.

    extractor : callable
        Should take a pydicom.dataset.Dataset and return a dictionary of the
        extracted meta data.

    force : bool
        Force reading source files even if they do not appear to be DICOM.

    warn_on_except : bool
        Convert exceptions into warnings, possibly allowing some results to be
        returned.

    stack_args : kwargs
        Keyword arguments to pass to the DicomStack constructor.
    '''
    results = parse_and_group(src_paths,
                              group_by,
                              extractor,
                              force,
                              warn_on_except)

    for key, group in results.items():
        results[key] = stack_group(group, warn_on_except, **stack_args)

    return results
