# *****************************************************************************
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation; either version 2 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc.,
# 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
#
# Module authors:
#   Damaris Tartarotti Maimone
#   Markus Zolliker <markus.zolliker@psi.ch>
# *****************************************************************************
"""Wrapper for the ADQ data acquisition card for ultrasound"""
import sys
import atexit
import signal
import time
import numpy as np
import ctypes as ct
from scipy.signal import butter, filtfilt


# For different trigger modes
SW_TRIG = 1
# The following external trigger does not work if the level of the trigger is very close to 0.5V.
# Now we have it close to 3V, and it works
EXT_TRIG_1 = 2
EXT_TRIG_2 = 7
EXT_TRIG_3 = 8
LVL_TRIG = 3
INT_TRIG = 4
LVL_FALLING = 0
LVL_RISING = 1

ADQ_CLOCK_INT_INTREF = 0  # internal clock source
ADQ_CLOCK_EXT_REF = 1  # internal clock source, external reference
ADQ_CLOCK_EXT_CLOCK = 2  # External clock source

ADQ_TRANSFER_MODE_NORMAL = 0x00
ADQ_CHANNELS_MASK = 0x3

GHz = 1e9
RMS_TO_VPP = 2 * np.sqrt(2)


class Timer:
    def __init__(self):
        self.data = [(time.time(), 'start')]

    def __call__(self, text=''):
        now = time.time()
        prev = self.data[-1][0]
        self.data.append((now, text))
        return now - prev

    def summary(self):
        return ' '.join(f'{txt} {tim:.3f}' for tim, txt in self.data[1:])

    def show(self):
        first = prev = self.data[0][0]
        print('---', first)
        for tim, txt in self.data[1:]:
            print(f'{(tim - first) * 1000:9.3f} {(tim - prev) * 1000:9.3f} ms {txt}')
            prev = tim


class Adq:
    sample_rate = 2 * GHz
    max_number_of_channels = 2
    ndecimate = 50  # decimation ratio (2GHz / 40 MHz)
    number_of_records = 1
    samples_per_record = 16384
    bw_cutoff = 10E6
    trigger = EXT_TRIG_1
    adq_num = 1
    data = None
    busy = False

    def __init__(self):
        global ADQAPI
        ADQAPI = ct.cdll.LoadLibrary("libadq.so.0")

        ADQAPI.ADQAPI_GetRevision()

        # Manually set return type from some ADQAPI functions
        ADQAPI.CreateADQControlUnit.restype = ct.c_void_p
        ADQAPI.ADQ_GetRevision.restype = ct.c_void_p
        ADQAPI.ADQ_GetPtrStream.restype = ct.POINTER(ct.c_int16)
        ADQAPI.ADQControlUnit_FindDevices.argtypes = [ct.c_void_p]
        # Create ADQControlUnit
        self.adq_cu = ct.c_void_p(ADQAPI.CreateADQControlUnit())
        ADQAPI.ADQControlUnit_EnableErrorTrace(self.adq_cu, 3, '.')

        # Find ADQ devices
        ADQAPI.ADQControlUnit_FindDevices(self.adq_cu)
        n_of_adq = ADQAPI.ADQControlUnit_NofADQ(self.adq_cu)
        if n_of_adq != 1:
            print('number of ADQs must be 1, not %d' % n_of_adq)
            print('it seems the ADQ was not properly closed')
            print('please try again or reboot')
            sys.exit(0)
        atexit.register(self.deletecu)
        signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))

        rev = ADQAPI.ADQ_GetRevision(self.adq_cu, self.adq_num)
        revision = ct.cast(rev, ct.POINTER(ct.c_int))
        out = [f'Connected to ADQ #1, FPGA Revision: {revision[0]}']
        if revision[1]:
            out.append('Local copy')
        else:
            if revision[2]:
                out.append('SVN Managed - Mixed Revision')
            else:
                out.append('SVN Updated')
        print(', '.join(out))
        ADQAPI.ADQ_SetClockSource(self.adq_cu, self.adq_num, ADQ_CLOCK_EXT_REF)

        ##########################
        # Test pattern
        # ADQAPI.ADQ_SetTestPatternMode(self.adq_cu, self.adq_num, 4)
        ##########################
        # Sample skip
        # ADQAPI.ADQ_SetSampleSkip(self.adq_cu, self.adq_num, 1)
        ##########################

        # set trigger mode
        if not ADQAPI.ADQ_SetTriggerMode(self.adq_cu, self.adq_num, self.trigger):
            raise RuntimeError('ADQ_SetTriggerMode failed.')
        if self.trigger == LVL_TRIG:
            if not ADQAPI.ADQ_SetLvlTrigLevel(self.adq_cu, self.adq_num, -100):
                raise RuntimeError('ADQ_SetLvlTrigLevel failed.')
            if not ADQAPI.ADQ_SetTrigLevelResetValue(self.adq_cu, self.adq_num, 1000):
                raise RuntimeError('ADQ_SetTrigLevelResetValue failed.')
            if not ADQAPI.ADQ_SetLvlTrigChannel(self.adq_cu, self.adq_num, 1):
                raise RuntimeError('ADQ_SetLvlTrigChannel failed.')
            if not ADQAPI.ADQ_SetLvlTrigEdge(self.adq_cu, self.adq_num, LVL_RISING):
                raise RuntimeError('ADQ_SetLvlTrigEdge failed.')
        elif self.trigger == EXT_TRIG_1:
            if not ADQAPI.ADQ_SetExternTrigEdge(self.adq_cu, self.adq_num, 2):
                raise RuntimeError('ADQ_SetLvlTrigEdge failed.')
        #    if not ADQAPI.ADQ_SetTriggerThresholdVoltage(self.adq_cu, self.adq_num, trigger, ct.c_double(0.2)):
        #            raise RuntimeError('SetTriggerThresholdVoltage failed.')
        # proabably the folloiwng is wrong.
        # print("CHANNEL:" + str(ct.c_int(ADQAPI.ADQ_GetLvlTrigChannel(self.adq_cu, self.adq_num))))

    def init(self, samples_per_record=None, number_of_records=None):
        """initialize dimensions and store result object"""
        if samples_per_record:
            self.samples_per_record = samples_per_record
        if number_of_records:
            self.number_of_records = number_of_records
        # Setup target buffers for data
        self.target_buffers = (ct.POINTER(ct.c_int16 * self.samples_per_record * self.number_of_records)
                               * self.max_number_of_channels)()
        for bufp in self.target_buffers:
            bufp.contents = (ct.c_int16 * self.samples_per_record * self.number_of_records)()

    def deletecu(self):
        cu = self.__dict__.pop('adq_cu', None)
        if cu is None:
            return
        print('shut down ADQ')
        # Only disarm trigger after data is collected
        ADQAPI.ADQ_DisarmTrigger(cu, self.adq_num)
        ADQAPI.ADQ_MultiRecordClose(cu, self.adq_num)
        # Delete ADQControlunit
        ADQAPI.DeleteADQControlUnit(cu)
        print('ADQ closed')

    def start(self, data):
        # Start acquisition
        ADQAPI.ADQ_MultiRecordSetup(self.adq_cu, self.adq_num,
                                    self.number_of_records,
                                    self.samples_per_record)

        ADQAPI.ADQ_DisarmTrigger(self.adq_cu, self.adq_num)
        ADQAPI.ADQ_ArmTrigger(self.adq_cu, self.adq_num)
        self.data = data

    def get_data(self):
        """get new data if available"""
        ready = False
        data = self.data
        if not data:
            self.busy = False
            return None  # no new data

        if ADQAPI.ADQ_GetAcquiredAll(self.adq_cu, self.adq_num):
            ready = True
            data.timer('ready')
        else:
            if self.trigger == SW_TRIG:
                ADQAPI.ADQ_SWTrig(self.adq_cu, self.adq_num)
        if not ready:
            self.busy = True
            return None
        self.data = None
        t = time.time()
        # Get data from ADQ
        if not ADQAPI.ADQ_GetData(
                    self.adq_cu, self.adq_num, self.target_buffers,
                    self.samples_per_record * self.number_of_records, 2,
                    0, self.number_of_records, ADQ_CHANNELS_MASK,
                    0, self.samples_per_record, ADQ_TRANSFER_MODE_NORMAL):
            raise RuntimeError('no success from ADQ_GetDATA')
        data.retrieve(self)
        return data


class PEdata:
    def __init__(self, adq):
        self.sample_rate = adq.sample_rate
        self.samp_freq = self.sample_rate / GHz
        self.number_of_records = adq.number_of_records
        self.timer = Timer()

    def retrieve(self, adq):
        data = []
        rawsignal = []
        for ch in range(2):
            onedim = np.frombuffer(adq.target_buffers[ch].contents, dtype=np.int16)
            rawsignal.append(onedim[:adq.samples_per_record])
            # convert 16 bit int to a value in the range -1 .. 1
            data.append(onedim.reshape(adq.number_of_records, adq.samples_per_record) / float(2 ** 15))
        # Now this is an array with all records, but the time is artificial
        self.data = data
        self.rawsignal = rawsignal
        self.timer('retrieved')

    def sinW(self, sig, freq, ti, tf):
        # sig: signal array
        # freq
        # ti, tf: initial and end time
        si = int(ti * self.samp_freq)
        nperiods = freq * (tf - ti)
        n = int(round(max(2, int(nperiods)) / nperiods * (tf-ti) * self.samp_freq))
        self.nperiods = n
        t = np.arange(si, len(sig)) / self.samp_freq
        t = t[:n]
        self.pulselen = n / self.samp_freq
        sig = sig[si:si+n]
        a = 2*np.sum(sig*np.cos(2*np.pi*freq*t))/len(sig)
        b = 2*np.sum(sig*np.sin(2*np.pi*freq*t))/len(sig)
        return a, b

    def mix(self, sigin, sigout, freq, ti, tf):
        # sigin, sigout: signal array, incomping, output
        # freq
        # ti, tf: initial and end time of sigin
        a, b = self.sinW(sigin, freq, ti, tf)
        amp = np.sqrt(a**2 + b**2)
        a, b = a/amp, b/amp
        # si = int(ti * self.samp_freq)
        t = np.arange(len(sigout)) / self.samp_freq
        wave1 = sigout * (a * np.cos(2*np.pi*freq*t) + b * np.sin(2*np.pi*freq*t))
        wave2 = sigout * (a * np.sin(2*np.pi*freq*t) - b * np.cos(2*np.pi*freq*t))
        return wave1, wave2

    def averageiq(self, data, freq, ti, tf):
        """Average over records"""
        iorq = np.array([self.mix(data[0][i], data[1][i], freq, ti, tf) for i in range(self.number_of_records)])
        return iorq.sum(axis=0) / self.number_of_records

    def filtro(self, iorq, cutoff):
        # butter lowpass
        nyq = 0.5 * self.sample_rate
        normal_cutoff = cutoff / nyq
        order = 5
        b, a = butter(order, normal_cutoff, btype='low', analog=False)
        iqf = [filtfilt(b, a, iorq[i]) for i in np.arange(len(iorq))]
        return iqf

    def box(self, iorq, ti, tf):
        si = int(self.samp_freq * ti)
        sf = int(self.samp_freq * tf)
        bxa = [sum(iorq[i][si:sf])/(sf-si) for i in np.arange(len(iorq))]
        return bxa

    def gates_and_curves(self, freq, pulse, roi, bw_cutoff):
        """return iq values of rois and prepare plottable curves for iq"""
        self.timer('gates')
        try:
            self.ndecimate = int(round(self.sample_rate / freq))
        except TypeError as e:
            raise TypeError(f'{self.sample_rate}/{freq}  {e}')
        iq = self.averageiq(self.data, freq / GHz, *pulse)
        self.timer('aviq')
        iqf = self.filtro(iq, bw_cutoff)
        self.timer('filtro')
        m = max(1, len(iqf[0]) // self.ndecimate)
        ll = m * self.ndecimate
        iqf = [iqfx[0:ll] for iqfx in iqf]
        self.timer('iqf')
        iqd = np.average(np.resize(iqf, (2, m, self.ndecimate)), axis=2)
        self.timer('avg')
        t_axis = np.arange(m) * self.ndecimate / self.samp_freq
        pulsig = np.abs(self.data[0][0])
        self.timer('pulsig')
        pulsig = np.average(np.resize(pulsig, (m, self.ndecimate)), axis=1)
        result = ([self.box(iqf, *r) for r in roi],  # gates
                  (t_axis, iqd[0], iqd[1], pulsig))  # curves
        self.timer('result')
        # self.timer.show()
        # ns = len(self.rawsignal[0]) * self.number_of_records
        # print(f'{ns} {ns / 2e6} ms')
        return result


class Namespace:
    """holds channel or other data"""
    def __init__(self, **kwds):
        self.__dict__.update(**kwds)


class RUSdata:
    def __init__(self, adq, freq, periods, delay_samples):
        self.sample_rate = adq.sample_rate
        self.freq = freq
        self.periods = periods
        self.delay_samples = delay_samples
        self.samples_per_record = adq.samples_per_record
        self.inp = Namespace(idx=0, name='input')
        self.out = Namespace(idx=1, name='output')
        self.channels = (self.inp, self.out)
        self.timer = Timer()

    def retrieve(self, adq):
        self.timer('start retrieve')
        npts = self.samples_per_record - self.delay_samples
        nbin = max(1, npts // (self.periods * 60))  # for performance reasons, do the binning first
        nreduced = npts // nbin
        ft = 2 * np.pi * self.freq * nbin / self.sample_rate * np.arange(nreduced)
        self.timer('create time axis')
        # complex_sinusoid = np.exp(1j * ft)  # do not use this, below is 33 % faster
        complex_sinusoid = 1j * np.sin(ft) + np.cos(ft)
        self.timer('sinusoid')

        rawsignal = []  # for raw plot
        for chan in self.channels:  # looping over input and output
            # although the ADC is only 14 bit it is represented as unsigend 16 bit numbers,
            # and due to some calculations (calibration) the last 2 bits are not zero
            beg = self.delay_samples
            isignal = np.frombuffer(adq.target_buffers[chan.idx].contents, dtype=np.int16)[beg:beg+nreduced * nbin]
            self.timer('isignal')
            reduced = isignal.reshape((-1, nbin)).mean(axis=1)  # this converts also int16 to float
            self.timer('reduce')
            rawsignal.append(reduced)
            chan.signal = signal = reduced * 2 ** -16  # in V -> peak to peak 1 V ~ +- 0.5 V
            self.timer('divide')
            # calculate RMS * sqrt(2) -> peak sinus amplitude.
            # may be higher than the input range by a factor 1.4 when heavily clipped
            chan.amplitude = np.sqrt((signal ** 2).mean()) * RMS_TO_VPP
            self.timer('amp')
            chan.mixed = signal * complex_sinusoid
            self.timer('mix')
            chan.mean = chan.mixed.mean()
            self.timer('mean')
        self.rawsignal = rawsignal
        if self.inp.mean:
            self.iq = self.out.mean / self.inp.mean
        else:
            self.iq = 0

    def get_quality(self):
        """get signal quality info

        quality info (small values indicate good quality):
        - input_stddev:
          the imaginary part indicates deviations in phase
          the real part indicates deviations in amplitude
        - output_slope:
          the imaginary part indicates a turning phase (rad/sec)
          the real part indicates changes in amplitude (0.01 ~= 1%/sec)
        """
        self.timer('get_quality')
        npts = len(self.channels[0].signal)
        nper = npts // self.periods
        for chan in self.channels:
            mean = chan.mixed.mean()
            chan.reduced = chan.mixed[:self.periods * nper].reshape((-1, nper)).mean(axis=1) / mean

        timeaxis = np.arange(len(self.out.reduced)) * self.sample_rate / self.freq
        result = Namespace(
            input_stddev=self.inp.reduced.std(),
            output_slope=np.polyfit(timeaxis, self.out.reduced, 1)[0])
        self.timer('got_quality')
        self.timer.show()
        ns = len(self.rawsignal[0])
        print(f'{ns} {ns / 2e6} ms')
        return result
