'''
Object oriented approach for the classical multiple scattering problem.
'''

__TODO__ = '''
- Implement per-scatterer lMax
  - This means that Scattering.TMatrices either can not be a single array with a fixed
    (N, 2, nelem, 2, nelem) shape but rather list of (2, nelem, 2, nelem) with nelem varying
    per particle or some of its elements have to be unused. Anyways, there has to be some kind of
    list with the lMaxes.
'''

import numpy as np
nx = np.newaxis
import time
import scipy
import sys
import warnings
import math
from qpms_c import get_mn_y, trans_calculator # TODO be explicit about what is imported
from .qpms_p import cart2sph, nelem2lMax # TODO be explicit about what is imported
from .timetrack import _time_b, _time_e

class Scattering(object):
    '''

    This is the most general class for a system of scatterers
    in a non-lossy homogeneous background
    to be solved with the multiple_scattering method. The scatterers,
    as long as they comply with the disjoint circumscribed sphere
    hypothesis, can each have any position in the 3D space and
    any T-matrix.

    Note that this object describes the scattering problem only for
    a single given frequency, as the T-matrices and wavelenght
    otherwise differ and all the computationally demanding
    parts have to be done for each frequency. However,
    the object can be recycled for many incident field shapes
    at the given frequency.

    Attributes should be perhaps later redefined to be read-only
    (or make descriptors for them).

    Args:
        positions: (N,3)-shaped real array
        TMatrices: (N,2,nelem,2,nelem)-shaped array
        k_0 (float): Wave number for the space between scatterers.

    Attributes:
        positions:
        TMatrices:
        k_0 (float): Wave number for the space between scatterers.
        lMax (int): Absolute maximum l for all scatterers. Depending on implementation,
            lMax can be smaller for some individual scatterers in certain subclasses.
            FIXME: here it is still implemented as constant lMax for all sites, see #!
        prepared (bool): Keeps information whether the interaction matrix has
            already been built and factorized.
        

    '''

    def __init__(self, positions, TMatrices, k_0, lMax = None, verbose=False, J_scat=3):
        self.J_scat = J_scat
        self.positions = positions.reshape((-1, positions.shape[-1]))
        self.interaction_matrix = None
        self.N = self.positions.shape[0]
        self.k_0 = k_0
        self.lMax = lMax if lMax else nelem2lMax(TMatrices.shape[-1])
        self.tc = trans_calculator(self.lMax)
        nelem = self.lMax * (self.lMax + 2) #!
        self.nelem = nelem #!
        self.prepared = False
        self.TMatrices = np.broadcast_to(TMatrices, (self.N,2,nelem,2,nelem))
        if np.isnan(np.min(TMatrices)):
            warnings.warn("Some TMatrices contain NaNs. Expect invalid results")
        if np.isnan(np.min(positions)):
            warnings.warn("positions contain NaNs. Expect invalid results")
        if math.isnan(k_0):
            warnings.warn("k_0 is NaN. Expect invalid results")



    def prepare(self, keep_interaction_matrix = False, verbose=False):
        btime = _time_b(verbose)
        if not self.prepared:
            if self.interaction_matrix is None:
                self.build_interaction_matrix(verbose=verbose)
            self.lupiv = scipy.linalg.lu_factor(self.interaction_matrix,overwrite_a = not keep_interaction_matrix)
            if not keep_interaction_matrix:
                self.interaction_matrix = None
            self.prepared = True
        _time_e(btime, verbose)

    def build_interaction_matrix(self,verbose = False):
        btime = _time_b(verbose)
        N = self.N
        my, ny = get_mn_y(self.lMax)
        nelem = len(my)
        leftmatrix = np.zeros((N,2,nelem,N,2,nelem), dtype=complex)
        sbtime = _time_b(verbose, step = 'Calculating interparticle translation coefficients')
        """
        for i in range(N):
            for j in range(N):
                for yi in range(nelem):
                    for yj in range(nelem):
                        if(i != j):
                            d_i2j = cart2sph(self.positions[j]-self.positions[i])
                            a = Ã(my[yj],ny[yj],my[yi],ny[yi],kdlj=d_i2j[0]*self.k_0,θlj=d_i2j[1],φlj=d_i2j[2],r_ge_d=False,J=self.J_scat)
                            b = B̃(my[yj],ny[yj],my[yi],ny[yi],kdlj=d_i2j[0]*self.k_0,θlj=d_i2j[1],φlj=d_i2j[2],r_ge_d=False,J=self.J_scat)
                            leftmatrix[j,0,yj,i,0,yi] = a
                            leftmatrix[j,1,yj,i,1,yi] = a
                            leftmatrix[j,0,yj,i,1,yi] = b
                            leftmatrix[j,1,yj,i,0,yi] = b
        """
        kdji = cart2sph(self.positions[:,nx,:] - self.positions[nx,:,:])
        kdji[:,:,0] *= self.k_0
        # get_AB array structure: [j,yj,i,yi]
        a, b = self.tc.get_AB(my[nx,:,nx,nx],ny[nx,:,nx,nx],my[nx,nx,nx,:],ny[nx,nx,nx,:],
                (kdji[:,:,0])[:,nx,:,nx], (kdji[:,:,1])[:,nx,:,nx], (kdji[:,:,2])[:,nx,:,nx],
                False,self.J_scat)
        mask = np.broadcast_to(np.eye(N,dtype=bool)[:,nx,:,nx],(N,nelem,N,nelem))
        a[mask] = 0 # no self-translations
        b[mask] = 0 
        leftmatrix[:,0,:,:,0,:] = a
        leftmatrix[:,1,:,:,1,:] = a
        leftmatrix[:,0,:,:,1,:] = b
        leftmatrix[:,1,:,:,0,:] = b
        _time_e(sbtime, verbose, step = 'Calculating interparticle translation coefficients')
        # at this point, leftmatrix is the translation matrix
        n2id = np.identity(2*nelem)
        n2id.shape = (2,nelem,2,nelem)
        for j in range(N):
            leftmatrix[j] = - np.tensordot(self.TMatrices[j],leftmatrix[j],axes=([-2,-1],[0,1]))
            # at this point, jth row of leftmatrix is that of -MT
            leftmatrix[j,:,:,j,:,:] += n2id
            # now we are done, 1-MT
        leftmatrix.shape=(N*2*nelem,N*2*nelem)
        self.interaction_matrix = leftmatrix
        _time_e(btime, verbose)

    def scatter(self, pq_0, verbose = False):
        '''
        pq_0 is (N, nelem, 2)-shaped array 
        '''
        btime = _time_b(verbose)
        if math.isnan(np.min(pq_0)):
            warnings.warn("The incident field expansion contains NaNs. Expect invalid results.")
        self.prepare(verbose=verbose)
        pq_0 = np.broadcast_to(pq_0, (self.N,2,self.nelem))
        MP_0 = np.empty((self.N,2,self.nelem),dtype=np.complex_)
        for j in range(self.N):
            MP_0[j] = np.tensordot(self.TMatrices[j], pq_0[j],axes=([-2,-1],[-2,-1]))
        MP_0.shape = (self.N*2*self.nelem,)
        solvebtime = _time_b(verbose,step='Solving the linear equation')
        ab = scipy.linalg.lu_solve(self.lupiv, MP_0)
        if math.isnan(np.min(ab)):
            warnings.warn("Got NaN in the scattering result. Damn.")
            raise
        _time_e(solvebtime, verbose, step='Solving the linear equation')
        ab.shape = (self.N,2,self.nelem)
        _time_e(btime, verbose)
        return ab
        
    def scatter_constmultipole(self, pq_0_c, verbose = False):
        btime = _time_b(verbose)
        N = self.N
        self.prepare(verbose=verbose)
        nelem = self.nelem
        if(pq_0_c ==1):
            pq_0_c = np.full((2,nelem),1)
        ab = np.empty((2,nelem,N*2*nelem), dtype=complex)
        for N_or_M in range(2):
            for yy in range(nelem):
                pq_0 = np.zeros((2,nelem),dtype=np.complex_)
                pq_0[N_or_M,yy] = pq_0_c[N_or_M,yy]
                pq_0 = np.broadcast_to(pq_0, (N,2,nelem))
                MP_0 = np.empty((N,2,nelem),dtype=np.complex_)
                for j in range(N):
                    MP_0[j] = np.tensordot(self.TMatrices[j], pq_0[j],axes=([-2,-1],[-2,-1]))
                MP_0.shape = (N*2*nelem,)
                ab[N_or_M,yy] = scipy.linalg.lu_solve(self.lupiv,MP_0)
        ab.shape = (2,nelem,N,2,nelem)
        _time_e(btime, verbose)
        return ab

class LatticeScattering(Scattering):
    def __init__(self, lattice_spec, k_0, zSym = False):
        pass


"""
class Scattering_2D_lattice_rectcells(Scattering):
    def __init__(self, rectcell_dims, rectcell_elem_positions, cellspec, k_0, rectcell_TMatrices = None, TMatrices = None, lMax = None, verbose=False, J_scat=3):
        '''
        cellspec: dvojice ve tvaru (seznam_zaplněnosti, seznam_pozic)
        '''
        if (rectcell_TMatrices is None) == (TMatrices is None):
            raise ValueError('Either rectcell_TMatrices or TMatrices has to be given')
        ###self.positions = ZDE JSEM SKONČIL
        self.J_scat = J_scat
        self.positions = positions
        self.interaction_matrix = None
        self.N = positions.shape[0]
        self.k_0 = k_0
        self.lMax = lMax if lMax else nelem2lMax(TMatrices.shape[-1])
        nelem = lMax * (lMax + 2) #!
        self.nelem = nelem #!
        self.prepared = False
        self.TMatrices = np.broadcast_to(TMatrices, (self.N,2,nelem,2,nelem))   
"""

class Scattering_2D_zsym(Scattering):
    def __init__(self, positions, TMatrices, k_0, lMax = None, verbose=False, J_scat=3):
        Scattering.__init__(self, positions, TMatrices, k_0, lMax, verbose, J_scat)
        #TODO some checks on TMatrices symmetry
        self.TE_yz = np.arange(self.nelem)
        self.TM_yz = self.TE_yz
        self.my, self.ny = get_mn_y(self.lMax)
        self.TE_NMz = (self.my + self.ny) % 2
        self.TM_NMz = 1 - self.TE_NMz
        self.tc = trans_calculator(self.lMax)
        # TODO možnost zadávat T-matice rovnou ve zhuštěné podobě
        TMatrices_TE = TMatrices[...,self.TE_NMz[:,nx],self.TE_yz[:,nx],self.TE_NMz[nx,:],self.TE_yz[nx,:]]
        TMatrices_TM = TMatrices[...,self.TM_NMz[:,nx],self.TM_yz[:,nx],self.TM_NMz[nx,:],self.TM_yz[nx,:]]
        self.TMatrices_TE = np.broadcast_to(TMatrices_TE, (self.N, self.nelem, self.nelem))
        self.TMatrices_TM = np.broadcast_to(TMatrices_TM, (self.N, self.nelem, self.nelem))
        self.prepared_TE = False
        self.prepared_TM = False
        self.interaction_matrix_TE = None
        self.interaction_matrix_TM= None

    def prepare_partial(self, TE_or_TM, keep_interaction_matrix = False, verbose=False):
        '''
        TE is 0, TM is 1.
        '''
        btime = _time_b(verbose)
        if (TE_or_TM == 0): #TE
            if not self.prepared_TE:
                if self.interaction_matrix_TE is None:
                    self.build_interaction_matrix(0, verbose)
                sbtime = _time_b(verbose, step = 'Calculating LU decomposition of the interaction matrix, TE part')
                self.lupiv_TE = scipy.linalg.lu_factor(self.interaction_matrix_TE, overwrite_a = not keep_interaction_matrix)
                _time_e(sbtime, verbose, step = 'Calculating LU decomposition of the interaction matrix, TE part')
                if(np.isnan(np.min(self.lupiv_TE[0])) or np.isnan(np.min(self.lupiv_TE[1]))):
                    warnings.warn("LU factorisation of interaction matrix contains NaNs. Expect invalid results.")
                self.prepared_TE = True
        if (TE_or_TM == 1): #TM
            if not self.prepared_TM:
                if self.interaction_matrix_TM is None:
                    self.build_interaction_matrix(1, verbose)
                sbtime = _time_b(verbose, step = 'Calculating LU decomposition of the interaction matrix, TM part')
                self.lupiv_TM = scipy.linalg.lu_factor(self.interaction_matrix_TM, overwrite_a = not keep_interaction_matrix)
                _time_e(sbtime, verbose, step = 'Calculating LU decomposition of the interaction matrix, TM part')
                if(np.isnan(np.min(self.lupiv_TM[0])) or np.isnan(np.min(self.lupiv_TM[1]))):
                    warnings.warn("LU factorisation of interaction matrix contains NaNs. Expect invalid results.")
                self.prepared_TM = True
        _time_e(btime, verbose)

    def prepare(self, keep_interaction_matrix = False, verbose=False):
        btime = _time_b(verbose)
        if not self.prepared:
            self.prepare_partial(0, keep_interaction_matrix, verbose)
            self.prepare_partial(1, keep_interaction_matrix, verbose)
            self.prepared = True
        _time_e(btime, verbose)

    def build_interaction_matrix(self,TE_or_TM = None, verbose = False):
        #None means both
        btime = _time_b(verbose)
        N = self.N
        my, ny = get_mn_y(self.lMax)
        nelem = len(my)
        idm = np.identity(nelem)
        if (TE_or_TM == 0):
            EoMl = (0,)
        elif (TE_or_TM == 1):
            EoMl = (1,)
        elif (TE_or_TM is None):
            EoMl = (0,1)
        sbtime = _time_b(verbose, step = 'Calculating interparticle translation coefficients')
        kdji = cart2sph(self.positions[:,nx,:] - self.positions[nx,:,:], allow2d=True)
        kdji[:,:,0] *= self.k_0
        # get_AB array structure: [j,yj,i,yi]
        # FIXME I could save some memory by calculating only half of these coefficients
        a, b = self.tc.get_AB(my[nx,:,nx,nx],ny[nx,:,nx,nx],my[nx,nx,nx,:],ny[nx,nx,nx,:],
                (kdji[:,:,0])[:,nx,:,nx], (kdji[:,:,1])[:,nx,:,nx], (kdji[:,:,2])[:,nx,:,nx],
                False,self.J_scat)
        mask = np.broadcast_to(np.eye(N,dtype=bool)[:,nx,:,nx],(N,nelem,N,nelem))
        a[mask] = 0 # no self-translations
        b[mask] = 0
        if np.isnan(np.min(a)) or np.isnan(np.min(b)):
            warnings.warn("Some of the translation coefficients is a NaN. Expect invalid results.")
        _time_e(sbtime, verbose, step = 'Calculating interparticle translation coefficients')
        for EoM in EoMl:
            leftmatrix = np.zeros((N,nelem,N,nelem), dtype=complex)
            y = np.arange(nelem)
            yi = y[nx,nx,nx,:]
            yj = y[nx,:,nx,nx]
            mask = np.broadcast_to((((yi - yj) % 2) == 0),(N,nelem,N,nelem))
            leftmatrix[mask] = a[mask]
            mask = np.broadcast_to((((yi - yj) % 2) != 0),(N,nelem,N,nelem))
            leftmatrix[mask] = b[mask]
            """ # we use to calculate the AB coefficients here
            for i in range(N):
                for j in range(i):
                    for yi in range(nelem):
                        for yj in range(nelem):
                            d_i2j = cart2sph(self.positions[j]-self.positions[i])
                            if ((yi - yj) % 2) == 0:
                                tr = Ã(my[yj],ny[yj],my[yi],ny[yi],kdlj=d_i2j[0]*self.k_0,θlj=d_i2j[1],φlj=d_i2j[2],r_ge_d=False,J=self.J_scat)
                            else:
                                tr = B̃(my[yj],ny[yj],my[yi],ny[yi],kdlj=d_i2j[0]*self.k_0,θlj=d_i2j[1],φlj=d_i2j[2],r_ge_d=False,J=self.J_scat)
                            leftmatrix[j,yj,i,yi] = tr
                            leftmatrix[i,yi,j,yj] = tr if (0 == (my[yj]+my[yi]) % 2) else -tr
            _time_e(sbtime, verbose, step = 'Calculating interparticle translation coefficients, T%s part' % ('M' if EoM else 'E'))
            """
            for j in range(N):
                leftmatrix[j] = - np.tensordot(self.TMatrices_TM[j] if EoM else self.TMatrices_TE[j],leftmatrix[j],
                        axes = ([-1],[0]))
                leftmatrix[j,:,j,:] += idm
            leftmatrix.shape = (self.N*self.nelem, self.N*self.nelem)
            if np.isnan(np.min(leftmatrix)):
                warnings.warn("Interaction matrix contains some NaNs. Expect invalid results.")
            if EoM == 0:
                self.interaction_matrix_TE = leftmatrix
            if EoM == 1:
                self.interaction_matrix_TM = leftmatrix
        a = None
        b = None
        _time_e(btime, verbose)

    def scatter_partial(self, TE_or_TM, pq_0, verbose = False):
        '''
        pq_0 is (N, nelem)-shaped array
        '''
        btime = _time_b(verbose)
        self.prepare_partial(TE_or_TM, verbose = verbose)
        pq_0 = np.broadcast_to(pq_0, (self.N, self.nelem))
        MP_0 = np.empty((self.N,self.nelem),dtype=np.complex_)
        for j in range(self.N):
            if TE_or_TM: #TM
                MP_0[j] = np.tensordot(self.TMatrices_TM[j], pq_0[j], axes=([-1],[-1]))
            else: #TE
                MP_0[j] = np.tensordot(self.TMatrices_TE[j], pq_0[j], axes=([-1],[-1]))
        MP_0.shape = (self.N*self.nelem,)
        solvebtime = _time_b(verbose,step='Solving the linear equation')
        ab = scipy.linalg.lu_solve(self.lupiv_TM if TE_or_TM else self.lupiv_TE, MP_0)
        _time_e(solvebtime, verbose, step='Solving the linear equation')
        ab.shape = (self.N, self.nelem)
        _time_e(btime,verbose)
        return ab

    def scatter(self, pq_0, verbose = False):
        '''
        FI7ME
        pq_0 is (N, nelem, 2)-shaped array 
        '''
        btime = _time_b(verbose)
        raise Exception('Not yet implemented')
        self.prepare(verbose=verbose)
        pq_0 = np.broadcast_to(pq_0, (self.N,2,self.nelem))
        MP_0 = np.empty((N,2,nelem),dtype=np.complex_)
        for j in range(self.N):
            MP_0[j] = np.tensordot(self.TMatrices[j], pq_0[j],axes=([-2,-1],[-2,-1]))
        MP_0.shape = (N*2*self.nelem,)
        solvebtime = _time_b(verbose,step='Solving the linear equation')
        ab = scipy.linalg.lu_solve(self.lupiv, MP_0)
        _time_e(solvebtime, verbose, step='Solving the linear equation')
        ab.shape = (N,2,nelem)
        _time_e(btime, verbose)
        return ab

    def forget_matrices(self):
        '''
        Free interaction matrices and set the respective flags 
        (useful when memory is a bottleneck).
        '''
        self.interaction_matrix_TE = None
        self.interaction_matrix_TM = None
        self.lupiv_TE = None
        self.lupiv_TM = None
        self.prepared_TE = False
        self.prepared_TM = False
        self.prepared = False


