import json
import os
from abc import ABCMeta, abstractmethod
from typing import List

import numpy as np

from piepline.utils.fsm import FolderRegistrable, FileStructManager
from piepline.train_config.metrics import MetricsGroup, AbstractMetric

__all__ = ['AbstractMonitor', 'AbstractMetricsMonitor', 'ConsoleLossMonitor', 'FileLogMonitor']


class AbstractMonitor(metaclass=ABCMeta):
    def __init__(self):
        self._epoch_num = 0

    def set_epoch_num(self, epoch_num: int) -> None:
        """
        Set current epoch num

        :param epoch_num: num of current epoch
        """
        self._epoch_num = epoch_num

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        pass


class AbstractLossMonitor(AbstractMonitor, metaclass=ABCMeta):
    """
    Basic class for every loss monitor
    """

    def update_losses(self, losses: {}) -> None:
        """
        Update losses on monitor

        :param losses: losses values dict with keys is names of stages in train pipeline (e.g. [train, validation])
        """
        pass

    @staticmethod
    def _iterate_by_losses(losses: {}, callback: callable) -> None:
        """
        Internal method for unify iteration by losses dict

        :param losses: dic of losses
        :param callback: callable, that call for every loss value and get params loss_name and loss_values: ``callback(name: str, values: np.ndarray)``
        """
        for m, v in losses.items():
            callback(m, v)


class AbstractMetricsMonitor(AbstractMonitor, metaclass=ABCMeta):
    """
    Basic class for every metrics monitor
    """

    def update_metrics(self, metrics: {}) -> None:
        """
        Update metrics on   monitor

        :param metrics: metrics dict with keys 'metrics' and 'groups'
        """
        for metric in metrics['metrics']:
            self._process_metric([], metric)

        for metrics_group in metrics['groups']:
            for metric in metrics_group.metrics():
                self._process_metric([metrics_group], metric)
            for group in metrics_group.groups():
                for metric in group.metrics():
                    self._process_metric([metrics_group, group], metric)

    @abstractmethod
    def _process_metric(self, path: List[MetricsGroup], metric: 'AbstractMetric'):
        """
        Internal method for process one metric

        Args:
            path (List[MetricsGroup]): list of parent metrics groups from root to current metric (except this metric)
        """


class ConsoleLossMonitor(AbstractLossMonitor):
    """
    Monitor, that used for write metrics to console.

    Output looks like: ``Epoch: [#]; train: [-1, 0, 1]; validation: [-1, 0, 1]``. This 3 numbers is [min, mean, max] values of
    training stage loss values
    """

    class ResStr:
        def __init__(self, start: str):
            self.res = start

        def append(self, string: str):
            self.res += string

        def __str__(self):
            return self.res[:len(self.res) - 1]

    def update_losses(self, losses: {}) -> None:
        def on_loss(name: str, values: np.ndarray, string) -> None:
            string.append(" {}: [{:4f}, {:4f}];".format(name, np.median(values), np.std(values)))

        res_string = self.ResStr("Epoch: [{}];".format(self._epoch_num))
        self._iterate_by_losses(losses, lambda m, v: on_loss(m, v, res_string))


class FileLogMonitor(AbstractMetricsMonitor, AbstractLossMonitor, FolderRegistrable):
    """
    Monitor, used for logging metrics in files. It's write full log and can also write last metrics in separate file if required

    All output files in JSON format and stores in ``<base_dir_path>/monitors/metrics_log``

    :param fsm: :class:`FileStructManager` object
    """

    def __init__(self, fsm: FileStructManager):
        super().__init__()

        self._fsm = fsm
        self._fsm.register_dir(self)
        self._files = {}
        self._meta_file = None
        self._final_file = None

    def _process_metric(self, path: List[MetricsGroup], metric: 'AbstractMetric'):
        file_log_dir = self._fsm.get_path(self, create_if_non_exists=True, check=False)

        intermediate_path = ''
        for p in path:
            intermediate_path = os.path.join(intermediate_path, p.name())
        cur_dir = os.path.join(file_log_dir, intermediate_path)

        if not os.path.exists(cur_dir):
            os.makedirs(cur_dir)

        metric_value = metric.get_value()

        cur_file_path = os.path.join(cur_dir, metric.name() + '.csv')
        with open(cur_file_path, 'a') as out:
            out.write("{}, {}\n".format(self._epoch_num, metric_value))

        if self._final_file is not None:
            final_file_path = os.path.join(file_log_dir, self._final_file)
            final_data = {}
            if os.path.exists(final_file_path):
                with open(final_file_path, 'r') as final_file:
                    final_data = json.load(final_file)
            final_data['/'.join([p.name() for p in path] + [metric.name()])] = metric_value
            with open(final_file_path, 'w') as final_file:
                json.dump(final_data, final_file)

        if cur_file_path not in self._files:
            self._files[cur_file_path] = {'name': metric.name(), 'path': [p.name() for p in path]}

            if self._meta_file is None:
                self._meta_file = os.path.join(file_log_dir, 'meta.json')

            with open(self._meta_file, 'w') as meta_out:
                json.dump(self._files, meta_out)

    def load(self) -> dict:
        cur_dir = self._fsm.get_path(self, create_if_non_exists=False, check=True)
        with open(os.path.join(cur_dir, 'meta.json'), 'r') as meta_file:
            meta = json.load(meta_file)

        res = {}
        for path in meta:
            cur_path = os.path.join(cur_dir, path)
            for f in os.listdir(cur_path):
                res[path] = np.loadtxt(os.path.join(cur_path, f), delimiter=',')

        return res

    def write_final_metrics(self, path: str = 'metrics.json') -> 'FileLogMonitor':
        self._final_file = path
        return self

    def get_dir(self) -> str:
        return self._fsm.get_path(self, create_if_non_exists=False, check=False)

    def _get_gir(self) -> str:
        return os.path.join('monitors', 'metrics_log')

    def _get_name(self) -> str:
        return 'FileLogMonitor'
