#  NanoVNASaver
#
#  A python program to view and export Touchstone data from a NanoVNA
#  Copyright (C) 2019, 2020  Rune B. Broberg
#  Copyright (C) 2020,2021 NanoVNA-Saver Authors
#
#  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 3 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, see <https://www.gnu.org/licenses/>.
import logging
import math
import cmath
import io
from operator import attrgetter

from typing import List

from scipy.interpolate import interp1d

from NanoVNASaver.RFTools import Datapoint

logger = logging.getLogger(__name__)


class Options:
    # Fun fact: In Touchstone 1.1 spec all params are optional unordered.
    # Just the line has to start with "#"
    UNIT_TO_FACTOR = {
        "ghz": 10 ** 9,
        "mhz": 10 ** 6,
        "khz": 10 ** 3,
        "hz": 10 ** 0,
    }
    VALID_UNITS = UNIT_TO_FACTOR.keys()
    VALID_PARAMETERS = "syzgh"
    VALID_FORMATS = ("ma", "db", "ri")

    def __init__(self,
                 unit: str = "GHZ",
                 parameter: str = "S",
                 t_format: str = "ma",
                 resistance: int = 50):
        # set defaults
        assert unit.lower() in Options.VALID_UNITS
        assert parameter.lower() in Options.VALID_PARAMETERS
        assert t_format.lower() in Options.VALID_FORMATS
        assert resistance > 0
        self.unit = unit.lower()
        self.parameter = parameter.lower()
        self.format = t_format.lower()
        self.resistance = resistance

    @property
    def factor(self) -> int:
        return Options.UNIT_TO_FACTOR[self.unit]

    def __str__(self) -> str:
        return (
            f"# {self.unit} {self.parameter}"
            f" {self.format} r {self.resistance}"
        ).upper()

    def parse(self, line: str):
        if not line.startswith("#"):
            raise TypeError(f"Not an option line: {line}")
        punit = pparam = pformat = presist = False
        params = iter(line[1:].lower().split())
        for p in params:
            if p in Options.VALID_UNITS and not punit:
                self.unit = p
                punit = True
            elif p in Options.VALID_PARAMETERS and not pparam:
                self.parameter = p
                pparam = True
            elif p in Options.VALID_FORMATS and not pformat:
                self.format = p
                pformat = True
            elif p == "r" and not presist:
                rstr = next(params)
                try:
                    self.resistance = int(rstr)
                except ValueError:
                    logger.warning("Non integer resistance value: %s", rstr)
                    self.resistance = int(float(rstr))
            else:
                raise TypeError(f"Illegal option line: {line}")


class Touchstone:
    FIELD_ORDER = ("11", "21", "12", "22")

    def __init__(self, filename: str = ""):
        self.filename = filename
        self.sdata = [[], [], [], []]  # at max 4 data pairs
        self.comments = []
        self.opts = Options()
        self._interp = {}

    @property
    def s11(self) -> List[Datapoint]:
        return self.s("11")

    @s11.setter
    def s11(self, value: List[Datapoint]):
        self.sdata[0] = value

    @property
    def s12(self) -> List[Datapoint]:
        return self.s("12")

    @s12.setter
    def s12(self, value: List[Datapoint]):
        self.sdata[2] = value

    @property
    def s21(self) -> List[Datapoint]:
        return self.s("21")

    @s21.setter
    def s21(self, value: List[Datapoint]):
        self.sdata[1] = value

    @property
    def s22(self) -> List[Datapoint]:
        return self.s("22")

    @s22.setter
    def s22(self, value: List[Datapoint]):
        self.sdata[3] = value

    @property
    def r(self) -> int:
        return self.opts.resistance

    def s(self, name: str) -> List[Datapoint]:
        return self.sdata[Touchstone.FIELD_ORDER.index(name)]

    def s_freq(self, name: str, freq: int) -> Datapoint:
        return Datapoint(freq,
                         float(self._interp[name]["real"](freq)),
                         float(self._interp[name]["imag"](freq)))

    def swap(self):
        self.sdata = [self.s22, self.s12, self.s21, self.s11]

    def min_freq(self) -> int:
        return self.s("11")[0].freq

    def max_freq(self) -> int:
        return self.s("11")[-1].freq

    def gen_interpolation(self):
        for i in Touchstone.FIELD_ORDER:
            freq = []
            real = []
            imag = []

            for dp in self.s(i):
                freq.append(dp.freq)
                real.append(dp.re)
                imag.append(dp.im)

            self._interp[i] = {
                "real": interp1d(freq, real,
                                 kind="slinear", bounds_error=False,
                                 fill_value=(real[0], real[-1])),
                "imag": interp1d(freq, imag,
                                 kind="slinear", bounds_error=False,
                                 fill_value=(imag[0], imag[-1])),
            }

    def _parse_comments(self, fp) -> str:
        for line in fp:
            line = line.strip()
            if line.startswith("!"):
                logger.info(line)
                self.comments.append(line)
                continue
            return line

    def _append_line_data(self, freq: int, data: list):
        data_list = iter(self.sdata)
        vals = iter(data)
        for v in vals:
            if self.opts.format == "ri":
                next(data_list).append(Datapoint(freq, float(v),
                                                 float(next(vals))))
            if self.opts.format == "ma":
                z = cmath.rect(float(v), math.radians(float(next(vals))))
                next(data_list).append(Datapoint(freq, z.real, z.imag))
            if self.opts.format == "db":
                z = cmath.rect(10 ** (float(v) / 20),
                               math.radians(float(next(vals))))
                next(data_list).append(Datapoint(freq, z.real, z.imag))

    def load(self):
        logger.info("Attempting to open file %s", self.filename)
        try:
            with open(self.filename, encoding='utf-8') as infile:
                self.loads(infile.read())
        except IOError as e:
            logger.exception("Failed to open %s: %s", self.filename, e)

    def loads(self, s: str):
        """Parse touchstone 1.1 string input
           appends to existing sdata if Touchstone object exists
        """
        try:
            self._loads(s)
        except TypeError as e:
            logger.exception("Failed to parse %s: %s", self.filename, e)

    def _loads(self, s: str):
        need_reorder = False
        with io.StringIO(s) as file:
            opts_line = self._parse_comments(file)
            self.opts.parse(opts_line)

            prev_freq = 0.0
            prev_len = 0
            for line in file:
                line = line.strip()
                # ignore empty lines (even if not specified)
                if line == "":
                    continue
                # accept comment lines after header
                if line.startswith("!"):
                    logger.warning("Comment after header: %s", line)
                    self.comments.append(line)
                    continue

                # ignore comments at data end
                data = line.split('!')[0]
                data = data.split()
                freq, data = round(float(data[0]) * self.opts.factor), data[1:]
                data_len = len(data)
                if data_len % 2 != 0:
                    raise TypeError("Data values aren't pairs: " + line)

                # consistency checks
                if freq <= prev_freq:
                    logger.warning("Frequency not ascending: %s", line)
                    need_reorder = True
                prev_freq = freq

                if prev_len == 0:
                    prev_len = data_len
                elif data_len != prev_len:
                    raise TypeError(f"Inconsistent number of pairs: {line}")

                self._append_line_data(freq, data)
            if need_reorder:
                logger.warning("Reordering data")
                for datalist in self.sdata:
                    datalist.sort(key=attrgetter("freq"))

    def save(self, nr_params: int = 1):
        """Save touchstone data to file.

        Args:
            nr_params: Number of s-parameters. 2 for s1p, 4 for s2p
        """

        logger.info("Attempting to open file %s for writing",
                    self.filename)
        with open(self.filename, "w", encoding="utf-8") as outfile:
            outfile.write(self.saves(nr_params))

    def saves(self, nr_params: int = 1) -> str:
        """Returns touchstone data as string.

        Args:
            nr_params: Number of s-parameters. 1 for s1p, 4 for s2p
        """
        assert nr_params in {1, 4}

        ts_str = "# HZ S RI R 50\n"
        for i, dp_s11 in enumerate(self.s11):
            ts_str += f"{dp_s11.freq} {dp_s11.re} {dp_s11.im}"
            for j in range(1, nr_params):
                dp = self.sdata[j][i]
                if dp.freq != dp_s11.freq:
                    raise LookupError("Frequencies of sdata not correlated")
                ts_str += f" {dp.re} {dp.im}"
            ts_str += "\n"
        return ts_str
