# emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*-
# vi: set ft=python sts=4 ts=4 sw=4 et:
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
#
#   See COPYING file distributed along with the NiBabel package for the
#   copyright and license terms.
#
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
''' Header and image reading / writing functions for MGH image format

Author: Krish Subramaniam
'''
from os.path import splitext
import numpy as np

from ..volumeutils import (array_to_file, array_from_file, Recoder)
from ..spatialimages import HeaderDataError, SpatialImage
from ..fileholders import FileHolder,  copy_file_map
from ..arrayproxy import ArrayProxy
from ..keywordonly import kw_only_meth

# mgh header
# See http://surfer.nmr.mgh.harvard.edu/fswiki/FsTutorial/MghFormat
DATA_OFFSET = 284
# Note that mgh data is strictly big endian ( hence the > sign )
header_dtd = [
    ('version', '>i4'),
    ('dims', '>i4', (4,)),
    ('type', '>i4'),
    ('dof', '>i4'),
    ('goodRASFlag', '>i2'),
    ('delta', '>f4', (3,)),
    ('Mdc', '>f4', (3, 3)),
    ('Pxyz_c', '>f4', (3,))
    ]
# Optional footer. Also has more stuff after this, optionally
footer_dtd = [
    ('mrparms', '>f4', (4,))
    ]

header_dtype = np.dtype(header_dtd)
footer_dtype = np.dtype(footer_dtd)
hf_dtype = np.dtype(header_dtd + footer_dtd)

# caveat: Note that it's ambiguous to get the code given the bytespervoxel
# caveat 2: Note that the bytespervox you get is in str ( not an int)
_dtdefs = (  # code, conversion function, dtype, bytes per voxel
    (0, 'uint8', '>u1', '1', 'MRI_UCHAR', np.uint8, np.dtype(np.uint8),
                         np.dtype(np.uint8).newbyteorder('>')),
    (4, 'int16', '>i2', '2', 'MRI_SHORT', np.int16, np.dtype(np.int16),
                         np.dtype(np.int16).newbyteorder('>')),
    (1, 'int32', '>i4', '4', 'MRI_INT', np.int32, np.dtype(np.int32),
                         np.dtype(np.int32).newbyteorder('>')),
    (3, 'float', '>f4', '4', 'MRI_FLOAT', np.float32, np.dtype(np.float32),
                         np.dtype(np.float32).newbyteorder('>')))

# make full code alias bank, including dtype column
data_type_codes = Recoder(_dtdefs, fields=('code', 'label', 'dtype',
                                           'bytespervox', 'mritype',
                                           'np_dtype1', 'np_dtype2',
                                           'numpy_dtype'))


class MGHError(Exception):
    """Exception for MGH format related problems.

    To be raised whenever MGH is not happy, or we are not happy with
    MGH.
    """
    pass


class MGHHeader(object):
    ''' Class for MGH format header

    The header also consists of the footer data which MGH places after the data
    chunk.
    '''
    # Copies of module-level definitions
    template_dtype = hf_dtype
    _hdrdtype = header_dtype
    _ftrdtype = footer_dtype
    _data_type_codes = data_type_codes

    def __init__(self,
                 binaryblock=None,
                 check=True):
        ''' Initialize header from binary data block

        Parameters
        ----------
        binaryblock : {None, string} optional
            binary block to set into header.  By default, None, in
            which case we insert the default empty header block
        check : bool, optional
            Whether to check content of header in initialization.
            Default is True.
        '''
        if binaryblock is None:
            self._header_data = self._empty_headerdata()
            return
        # check size
        if len(binaryblock) != self.template_dtype.itemsize:
            raise HeaderDataError('Binary block is wrong size')
        hdr = np.ndarray(shape=(),
                         dtype=self.template_dtype,
                         buffer=binaryblock)
        #if goodRASFlag, discard delta, Mdc and c_ras stuff
        if int(hdr['goodRASFlag']) < 0:
            hdr = self._set_affine_default(hdr)
        self._header_data = hdr.copy()
        if check:
            self.check_fix()
        return

    def __str__(self):
        ''' Print the MGH header object information
        '''
        txt = []
        txt.append(str(self.__class__))
        txt.append('Dims: ' + str(self.get_data_shape()))
        code = int(self._header_data['type'])
        txt.append('MRI Type: ' + self._data_type_codes.mritype[code])
        txt.append('goodRASFlag: ' + str(self._header_data['goodRASFlag']))
        txt.append('delta: ' + str(self._header_data['delta']))
        txt.append('Mdc: ')
        txt.append(str(self._header_data['Mdc']))
        txt.append('Pxyz_c: ' + str(self._header_data['Pxyz_c']))
        txt.append('mrparms: ' + str(self._header_data['mrparms']))
        return '\n'.join(txt)

    def __getitem__(self, item):
        ''' Return values from header data
        '''
        return self._header_data[item]

    def __setitem__(self, item, value):
        ''' Set values in header data
        '''
        self._header_data[item] = value

    def __iter__(self):
        return iter(self.keys())

    def keys(self):
        ''' Return keys from header data'''
        return list(self.template_dtype.names)

    def values(self):
        ''' Return values from header data'''
        data = self._header_data
        return [data[key] for key in self.template_dtype.names]

    def items(self):
        ''' Return items from header data'''
        return zip(self.keys(), self.values())

    @classmethod
    def from_header(klass, header=None, check=True):
        ''' Class method to create MGH header from another MGH header
        '''
        # own type, return copy
        if type(header) == klass:
            obj = header.copy()
            if check:
                obj.check_fix()
            return obj
        # not own type, make fresh header instance
        obj = klass(check=check)
        return obj

    @classmethod
    def from_fileobj(klass, fileobj, check=True):
        '''
        classmethod for loading a MGH fileobject
        '''
        # We need the following hack because MGH data stores header information
        # after the data chunk too. We read the header initially, deduce the
        # dimensions from the header, skip over and then read the footer
        # information
        hdr_str = fileobj.read(klass._hdrdtype.itemsize)
        hdr_str_to_np = np.ndarray(shape=(),
                         dtype=klass._hdrdtype,
                         buffer=hdr_str)
        if not np.all(hdr_str_to_np['dims']):
            raise MGHError('Dimensions of the data should be non-zero')
        tp = int(hdr_str_to_np['type'])
        fileobj.seek(DATA_OFFSET + \
                int(klass._data_type_codes.bytespervox[tp]) * \
                np.prod(hdr_str_to_np['dims']))
        ftr_str = fileobj.read(klass._ftrdtype.itemsize)
        return klass(hdr_str + ftr_str, check)

    @property
    def binaryblock(self):
        ''' binary block of data as string

        Returns
        -------
        binaryblock : string
            string giving binary data block

        '''
        return self._header_data.tostring()

    def copy(self):
        ''' Return copy of header
        '''
        return self.__class__(self.binaryblock, check=False)

    def __eq__(self, other):
        ''' equality between two MGH format headers

        Examples
        --------
        >>> wstr = MGHHeader()
        >>> wstr2 = MGHHeader()
        >>> wstr == wstr2
        True
        '''
        return self.binaryblock == other.binaryblock

    def __ne__(self, other):
        return not self == other

    def check_fix(self):
        ''' Pass. maybe for now'''
        pass

    def get_affine(self):
        ''' Get the affine transform from the header information.
        MGH format doesn't store the transform directly. Instead it's gleaned
        from the zooms ( delta ), direction cosines ( Mdc ), RAS centers (
        Pxyz_c ) and the dimensions.
        '''
        hdr = self._header_data
        d = np.diag(hdr['delta'])
        pcrs_c = hdr['dims'][:3] / 2.0
        Mdc = hdr['Mdc'].T
        pxyz_0 = hdr['Pxyz_c'] - np.dot(Mdc, np.dot(d, pcrs_c))
        M = np.eye(4, 4)
        M[0:3, 0:3] = np.dot(Mdc, d)
        M[0:3, 3] = pxyz_0.T
        return M

    # For compatibility with nifti (multiple affines)
    get_best_affine = get_affine

    def get_vox2ras(self):
        '''return the get_affine()
        '''
        return self.get_affine()

    def get_vox2ras_tkr(self):
        ''' Get the vox2ras-tkr transform. See "Torig" here:
                http://surfer.nmr.mgh.harvard.edu/fswiki/CoordinateSystems
        '''
        ds = np.array(self._header_data['delta'])
        ns = (np.array(self._header_data['dims'][:3]) * ds) / 2.0
        v2rtkr = np.array([[-ds[0], 0, 0, ns[0]],
                           [0, 0, ds[2], -ns[2]],
                           [0, -ds[1], 0, ns[1]],
                           [0, 0, 0, 1]], dtype=np.float32)
        return v2rtkr

    def get_ras2vox(self):
        '''return the inverse get_affine()
        '''
        return np.linalg.inv(self.get_affine())

    def get_data_dtype(self):
        ''' Get numpy dtype for MGH data

        For examples see ``set_data_dtype``
        '''
        code = int(self._header_data['type'])
        dtype = self._data_type_codes.numpy_dtype[code]
        return dtype

    def set_data_dtype(self, datatype):
        ''' Set numpy dtype for data from code or dtype or type
        '''
        try:
            code = self._data_type_codes[datatype]
        except KeyError:
            raise MGHError('datatype dtype "%s" not recognized' % datatype)
        self._header_data['type'] = code

    def get_zooms(self):
        ''' Get zooms from header

        Returns
        -------
        z : tuple
           tuple of header zoom values
        '''
        hdr = self._header_data
        zooms = hdr['delta']
        return tuple(zooms[:])

    def set_zooms(self, zooms):
        ''' Set zooms into header fields

        See docstring for ``get_zooms`` for examples
        '''
        hdr = self._header_data
        zooms = np.asarray(zooms)
        if len(zooms) != len(hdr['delta']):
            raise HeaderDataError('Expecting %d zoom values for ndim'
                                  % hdr['delta'])
        if np.any(zooms < 0):
            raise HeaderDataError('zooms must be positive')
        delta = hdr['delta']
        delta[:] = zooms[:]

    def get_data_shape(self):
        ''' Get shape of data
        '''
        dims = self._header_data['dims'][:]
        # If last dimension (nframes) is 1, remove it because
        # we want to maintain 3D and it's redundant
        if int(dims[-1]) == 1:
            dims = dims[:-1]
        return tuple(int(d) for d in dims)

    def set_data_shape(self, shape):
        ''' Set shape of data

        Parameters
        ----------
        shape : sequence
           sequence of integers specifying data array shape
        '''
        dims = self._header_data['dims']
        # If len(dims) is 3, add a dimension. MGH header always
        # needs 4 dimensions.
        if len(shape) == 3:
            shape = list(shape)
            shape.append(1)
            shape = tuple(shape)
        dims[:] = shape
        self._header_data['delta'][:] = 1.0

    def get_data_bytespervox(self):
        ''' Get the number of bytes per voxel of the data
        '''
        return int(self._data_type_codes.bytespervox[ \
            int(self._header_data['type'])])

    def get_data_size(self):
        ''' Get the number of bytes the data chunk occupies.
        '''
        return self.get_data_bytespervox() * np.prod(self._header_data['dims'])

    def get_data_offset(self):
        ''' Return offset into data file to read data
        '''
        return DATA_OFFSET

    def get_footer_offset(self):
        ''' Return offset where the footer resides.
            Occurs immediately after the data chunk.
        '''
        return self.get_data_offset() + self.get_data_size()

    def data_from_fileobj(self, fileobj):
        ''' Read data array from `fileobj`

        Parameters
        ----------
        fileobj : file-like
           Must be open, and implement ``read`` and ``seek`` methods

        Returns
        -------
        arr : ndarray
           data array
        '''
        dtype = self.get_data_dtype()
        shape = self.get_data_shape()
        offset = self.get_data_offset()
        return array_from_file(shape, dtype, fileobj, offset)

    def get_slope_inter(self):
        """ MGH format does not do scaling?
        """
        return None, None

    def _empty_headerdata(self):
        ''' Return header data for empty header
        '''
        dt = self.template_dtype
        hdr_data = np.zeros((), dtype=dt)
        hdr_data['version'] = 1
        hdr_data['dims'][:] = np.array([1, 1, 1, 1])
        hdr_data['type'] = 3
        hdr_data['goodRASFlag'] = 1
        hdr_data['delta'][:] = np.array([1, 1, 1])
        hdr_data['Mdc'][0][:] = np.array([-1, 0, 0])  # x_ras
        hdr_data['Mdc'][1][:] = np.array([0, 0, -1])  # y_ras
        hdr_data['Mdc'][2][:] = np.array([0, 1, 0])   # z_ras
        hdr_data['Pxyz_c'] = np.array([0, 0, 0])  # c_ras
        hdr_data['mrparms'] = np.array([0, 0, 0, 0])
        return hdr_data

    def _set_affine_default(self, hdr):
        ''' If  goodRASFlag is 0, return the default delta, Mdc and Pxyz_c
        '''
        hdr['delta'][:] = np.array([1, 1, 1])
        hdr['Mdc'][0][:] = np.array([-1, 0, 0])  # x_ras
        hdr['Mdc'][1][:] = np.array([0, 0, -1])  # y_ras
        hdr['Mdc'][2][:] = np.array([0, 1, 0])   # z_ras
        hdr['Pxyz_c'][:] = np.array([0, 0, 0])   # c_ras
        return hdr

    def writehdr_to(self, fileobj):
        ''' Write header to fileobj

        Write starts at the beginning.

        Parameters
        ----------
        fileobj : file-like object
           Should implement ``write`` and ``seek`` method

        Returns
        -------
        None
        '''
        hdr_nofooter = np.ndarray((), dtype=self._hdrdtype,
                                  buffer=self.binaryblock)
        # goto the very beginning of the file-like obj
        fileobj.seek(0)
        fileobj.write(hdr_nofooter.tostring())

    def writeftr_to(self, fileobj):
        ''' Write footer to fileobj

        Footer data is located after the data chunk. So move there and write.

        Parameters
        ----------
        fileobj : file-like object
           Should implement ``write`` and ``seek`` method

        Returns
        -------
        None
        '''
        ftr_loc_in_hdr = len(self.binaryblock) - self._ftrdtype.itemsize
        ftr_nd = np.ndarray((), dtype=self._ftrdtype,
                            buffer=self.binaryblock, offset=ftr_loc_in_hdr)
        fileobj.seek(self.get_footer_offset())
        fileobj.write(ftr_nd.tostring())


class MGHImage(SpatialImage):
    """ Class for MGH format image
    """
    header_class = MGHHeader
    files_types = (('image', '.mgh'),)
    _compressed_exts = (('.gz',))

    ImageArrayProxy = ArrayProxy

    @classmethod
    def filespec_to_file_map(klass, filespec):
        """ Check for compressed .mgz format, then .mgh format """
        if splitext(filespec)[1].lower() == '.mgz':
            return dict(image=FileHolder(filename=filespec))
        return super(MGHImage, klass).filespec_to_file_map(filespec)

    @classmethod
    @kw_only_meth(1)
    def from_file_map(klass, file_map, mmap=True):
        '''Load image from `file_map`

        Parameters
        ----------
        file_map : None or mapping, optional
           files mapping.  If None (default) use object's ``file_map``
           attribute instead
        mmap : {True, False, 'c', 'r'}, optional, keyword only
            `mmap` controls the use of numpy memory mapping for reading image
            array data.  If False, do not try numpy ``memmap`` for data array.
            If one of {'c', 'r'}, try numpy memmap with ``mode=mmap``.  A `mmap`
            value of True gives the same behavior as ``mmap='c'``.  If image
            data file cannot be memory-mapped, ignore `mmap` value and read
            array from file.
        '''
        if not mmap in (True, False, 'c', 'r'):
            raise ValueError("mmap should be one of {True, False, 'c', 'r'}")
        mghf = file_map['image'].get_prepare_fileobj('rb')
        header = klass.header_class.from_fileobj(mghf)
        affine = header.get_affine()
        hdr_copy = header.copy()
        data = klass.ImageArrayProxy(mghf, hdr_copy, mmap=mmap)
        img = klass(data, affine, header, file_map=file_map)
        img._load_cache = {'header': hdr_copy,
                           'affine': affine.copy(),
                           'file_map': copy_file_map(file_map)}
        return img

    @classmethod
    @kw_only_meth(1)
    def from_filename(klass, filename, mmap=True):
        ''' class method to create image from filename `filename`

        Parameters
        ----------
        filename : str
            Filename of image to load
        mmap : {True, False, 'c', 'r'}, optional, keyword only
            `mmap` controls the use of numpy memory mapping for reading image
            array data.  If False, do not try numpy ``memmap`` for data array.
            If one of {'c', 'r'}, try numpy memmap with ``mode=mmap``.  A `mmap`
            value of True gives the same behavior as ``mmap='c'``.  If image
            data file cannot be memory-mapped, ignore `mmap` value and read
            array from file.

        Returns
        -------
        img : MGHImage instance
        '''
        if not mmap in (True, False, 'c', 'r'):
            raise ValueError("mmap should be one of {True, False, 'c', 'r'}")
        file_map = klass.filespec_to_file_map(filename)
        return klass.from_file_map(file_map, mmap=mmap)

    load = from_filename

    def to_file_map(self, file_map=None):
        ''' Write image to `file_map` or contained ``self.file_map``

        Parameters
        ----------
        file_map : None or mapping, optional
           files mapping.  If None (default) use object's ``file_map``
           attribute instead
        '''
        if file_map is None:
            file_map = self.file_map
        data = self.get_data()
        self.update_header()
        hdr = self.header
        with file_map['image'].get_prepare_fileobj('wb') as mghf:
            hdr.writehdr_to(mghf)
            self._write_data(mghf, data, hdr)
            self._write_footer(mghf, hdr)
        self._header = hdr
        self.file_map = file_map

    def _write_data(self, mghfile, data, header):
        ''' Utility routine to write image

        Parameters
        ----------
        mghfile : file-like
           file-like object implementing ``seek`` or ``tell``, and
           ``write``
        data : array-like
           array to write
        header : analyze-type header object
           header
        '''
        shape = header.get_data_shape()
        if data.shape != shape:
            raise HeaderDataError('Data should be shape (%s)' %
                                  ', '.join(str(s) for s in shape))
        offset = header.get_data_offset()
        out_dtype = header.get_data_dtype()
        array_to_file(data, mghfile, out_dtype, offset)

    def _write_footer(self, mghfile, header):
        ''' Utility routine to write header. This write the footer data
        which occurs after the data chunk in mgh file

        Parameters
        ----------
        mghfile : file-like
           file-like object implementing ``write``, open for writing
        header : header object
        '''
        header.writeftr_to(mghfile)

    def _affine2header(self):
        """ Unconditionally set affine into the header """
        hdr = self._header
        shape = self._dataobj.shape
        # for more information, go through save_mgh.m in FreeSurfer dist
        MdcD = self._affine[:3, :3]
        delta = np.sqrt(np.sum(MdcD * MdcD, axis=0))
        Mdc = MdcD / np.tile(delta, (3, 1))
        Pcrs_c = np.array([0, 0, 0, 1], dtype=np.float)
        Pcrs_c[:3] = np.array(shape[:3]) / 2.0
        Pxyz_c = np.dot(self._affine, Pcrs_c)

        hdr['delta'][:] = delta
        hdr['Mdc'][:, :] = Mdc.T
        hdr['Pxyz_c'][:] = Pxyz_c[:3]


load = MGHImage.load
save = MGHImage.instance_to_filename
