from __future__ import annotations

import re
import struct
import warnings
from pathlib import Path

import numpy as np

from ..data import BDS, Points

MAGIC_RE = re.compile(b'NOVOCONTROL DK-RESULTS')


class BDSReader:
    def __init__(self, fname: str = None):
        self.opts = {}
        self.fname = None
        self.x = None
        self.y = None

        if fname is not None:
            self.fname = Path(fname)
            self._parse_file()

    def __call__(self, fname: str):
        self.fname = Path(fname)
        self._parse_file()

        return self

    def __getitem__(self, val):
        return self.opts[val]

    def _parse_file(self):
        self.opts = {}
        with self.fname.open(mode='rb') as f:
            (magic_part,) = struct.unpack('<22s', f.read(22))
            if re.match(MAGIC_RE, magic_part) is None:
                raise TypeError(f'{self.fname} not a valid file')

            f.seek(48)
            self.opts['voltage'] = struct.unpack('f', f.read(4))[0]
            self.opts['parameter'] = list(struct.unpack('i', f.read(4)))

            f.seek(2, 1)
            self.opts['edge correction'] = bool(struct.unpack('b', f.read(1))[0])
            f.seek(1, 1)
            self.opts['electrode thickness'] = struct.unpack('f', f.read(4))[0]

            self.opts['parameter'] += struct.unpack('3f', f.read(12))  # 1, 0.05, 0.06, 0.02

            f.seek(2, 1)
            self.opts['capacity'] = struct.unpack('f', f.read(4))[0]
            self.opts['spacer area'] = struct.unpack('f', f.read(4))[0]
            f.seek(20, 1)

            self.opts['diameter'] = struct.unpack('f', f.read(4))[0]
            self.opts['area'] = self.opts['diameter']**2 * np.pi / 4
            self.opts['thickness'] = struct.unpack('f', f.read(4))[0]
            self.opts['C_0'] = (self.opts['area']-self.opts['spacer area']) / self.opts['thickness'] * 8.8541878128e-12

            f.seek(158)
            name_length = struct.unpack('i', f.read(4))[0]

            f.seek(2, 1)
            b = f.read(name_length)
            self.opts['name'] = str(struct.unpack(f'<{name_length}s', b)[0][:-1], encoding='utf-8')

            _ = struct.unpack('h', f.read(2))
            freq_values_length = struct.unpack('i', f.read(4))[0]

            f.seek(4, 1)
            self.x = np.empty(freq_values_length)
            for freqs in range(freq_values_length):
                (self.x[freqs],) = struct.unpack('f', f.read(4))

            _ = struct.unpack('h', f.read(2))  # TODO if no temperature set this is not 400 and rest breaks
            length_temps = struct.unpack('i', f.read(4))[0]

            f.seek(4, 1)
            self.opts['temperatures'] = np.array(struct.unpack('f' * length_temps, f.read(4*length_temps)))

            f.seek(110, 1)
            date_length = (struct.unpack('i', f.read(4)))[0]

            f.seek(2, 1)
            self.opts['date'] = str(struct.unpack(f'<{date_length}s', f.read(date_length))[0][:-1],
                                    encoding='utf-8')

            f.seek(47, 1)

            _y = []
            actual_temps_length = 0
            read_length = 9 * freq_values_length  # per entry 9 * 4 byte
            while True:
                # catch error if measurement was aborted
                try:
                    y_i = struct.unpack('f' * read_length, f.read(4*read_length))
                    if any([y_ii != 0.0 for y_ii in y_i[-3:]]):
                        # the last three should be zero
                        break

                    _y += y_i
                    actual_temps_length += 1
                    if actual_temps_length == length_temps:
                        break

                except struct.error:
                    break

            if actual_temps_length != length_temps:
                warnings.warn('Number of set temperatures does not match number of data points')

            _y = np.array(_y).reshape((actual_temps_length, freq_values_length, 9))

            # last 3 entries are zero, save only 6
            # Z.imag*omega), Z.real, meas.time, meas. temp., ac voltage, dc voltage
            self.y = np.transpose(_y[:, :, :6], (2, 0, 1))

    def export(self, ymode: str = 'epsilon', xmode: str = 'freq') -> list[BDS]:
        y = getattr(self, ymode)()

        useful_info = {
            'voltage': self.opts['voltage'],
            'diameter': self.opts['diameter'],
            'area': self.opts['area'],
            'thickness': self.opts['thickness'],
            'mode': ymode
        }

        if xmode == 'freq':
            fmt = '.2f'
            x2 = self.temperature
            x = self.frequencies

        elif xmode == 'temp':
            fmt = '.2f'
            x2 = self.frequencies
            x = self.temperature
            y = y.T

        else:
            raise ValueError(f'Unknown mode {xmode!r}, use `freq` or `temp`.')

        ret_val = []
        for i, label in enumerate(x2):
            y_i = y[i]
            if np.allclose(y_i.imag, 0):
                ret_val.append(Points(x=x, y=y_i,
                                      name=f'{label:{fmt}}', value=label, **useful_info))
            else:
                ret_val.append(BDS(x=x, y=y_i,
                                   name=f'{label:{fmt}}', value=label, **useful_info))

        return ret_val

    def capacity(self):
        # NOTE: nobody ever used edge correction, so we ignore this option entirely
        return self.y[0] - self.opts['capacity'] - 1j / self.y[1] / (self.frequencies*2*np.pi)

    def sample_temp(self):
        return self.y[2]

    def time(self):
        return self.y[3]

    def sigma(self):
        # sigma^* = i * omega * eps_0 * (eps^* - 1)
        conv = 0.01 * 8.8541878128e-12 * 2*np.pi*self.x  # factor 0.01: sigma in S/cm
        return conv * (self.epsilon() - 1)

    def epsilon(self):
        # eps^* = Cp^* d/A/eps_0
        c = self.capacity()
        return (c.real - 1j * c.imag) / self.opts['C_0']

    def modulus(self):
        # M^* = 1/eps^*
        return np.conjugate(1/self.epsilon())

    def loss_factor(self):
        eps = self.epsilon()

        return eps.imag / eps.real

    @property
    def set_temp(self):
        return self.opts['temperatures'][:len(self.temperature)]

    @property
    def temperature(self):
        return self.sample_temp().mean(axis=1)

    @property
    def frequencies(self):
        return self.x
