# SPDX-FileCopyrightText: PhiBo DinoTools (2021)
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import date, datetime, time
from decimal import Decimal
from pprint import pformat
import re
import ssl
from typing import Any, Dict, List, Optional, Union

import librouteros
import librouteros.query
import nagiosplugin

from .helper import logger, RouterOSVersion
from .exeption import MissingValue


class RouterOSCheckResource(nagiosplugin.Resource):
    month_mapping: Dict[str, int] = {
        "jan": 1,
        "feb": 2,
        "mar": 3,
        "apr": 4,
        "may": 5,
        "jun": 6,
        "jul": 7,
        "aug": 8,
        "sep": 9,
        "oct": 10,
        "nov": 11,
        "dec": 12,
    }

    regex_datetime = re.compile(
        r"(?P<month>[a-z]{3})/(?P<day>\d+)/(?P<year>\d{4})\s+(?P<hour>\d+):(?P<minute>\d+):(?P<second>\d+)",
        flags=re.IGNORECASE
    )

    regex_date = re.compile(
        r"(?P<month>[a-z]{3})/(?P<day>\d+)/(?P<year>\d{4})",
        flags=re.IGNORECASE
    )

    regex_date_iso = re.compile(
        r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})",
        flags=re.IGNORECASE
    )

    regex_time = re.compile(
        r"(?P<hour>\d+):(?P<minute>\d+):(?P<second>\d+)",
        flags=re.IGNORECASE
    )

    def __init__(self, cmd_options: Dict[str, Any], check: nagiosplugin.Check):
        self._cmd_options = cmd_options
        self._check = check
        self._routeros_metric_values: List[Dict[str, Any]] = []
        self._routeros_version: Optional[RouterOSVersion] = None
        self._api: Optional[librouteros.api.Api] = None
        self.current_time = datetime.now()

    @property
    def api(self):
        if self._api is None:
            self._api = self.connect_api()

        return self._api

    @property
    def routeros_version(self):
        if self._routeros_version is None:
            if self._cmd_options["routeros_version"].strip().lower() == "auto":
                self._routeros_version = self._get_routeros_version()
            else:
                self._routeros_version = RouterOSVersion(self._cmd_options["routeros_version"].strip())

        return self._routeros_version

    @staticmethod
    def _calc_rate(
            cookie: nagiosplugin.Cookie,
            name: str,
            cur_value: int,
            elapsed_seconds: Optional[float],
            factor: int
    ) -> float:
        old_value: Optional[int] = cookie.get(name)
        cookie[name] = cur_value
        if old_value is None:
            raise MissingValue(f"Unable to find old value for '{name}'")
        if elapsed_seconds is None:
            raise MissingValue("Unable to get elapsed seconds")
        return (cur_value - old_value) / elapsed_seconds * factor

    def _connect_api(self) -> librouteros.api.Api:
        def wrap_socket(socket):
            server_hostname: Optional[str] = self._cmd_options["hostname"]
            if server_hostname is None:
                server_hostname = self._cmd_options["host"]
            return ssl_ctx.wrap_socket(socket, server_hostname=server_hostname)

        logger.info("Connecting to device ...")
        port = self._cmd_options["port"]
        extra_kwargs = {}
        if self._cmd_options["ssl"]:
            if port is None:
                port = 8729

            context_kwargs = {}
            if self._cmd_options["ssl_cafile"]:
                context_kwargs["cafile"] = self._cmd_options["ssl_cafile"]
            if self._cmd_options["ssl_capath"]:
                context_kwargs["capath"] = self._cmd_options["ssl_capath"]

            ssl_ctx = ssl.create_default_context(**context_kwargs)

            if self._cmd_options["ssl_force_no_certificate"]:
                ssl_ctx.check_hostname = False
                ssl_ctx.set_ciphers("ADH:@SECLEVEL=0")
            elif not self._cmd_options["ssl_verify"]:
                # We have do disable hostname check if we disable certificate verification
                ssl_ctx.check_hostname = False
                ssl_ctx.verify_mode = ssl.CERT_NONE
            elif not self._cmd_options["ssl_verify_hostname"]:
                ssl_ctx.check_hostname = False

            extra_kwargs["ssl_wrapper"] = wrap_socket
        else:
            if port is None:
                port = 8728

        api = librouteros.connect(
            host=self._cmd_options["host"],
            username=self._cmd_options["username"],
            password=self._cmd_options["password"],
            port=port,
            **extra_kwargs
        )
        return api

    @staticmethod
    def _convert_v6_list_to_v7(api_results) -> List[Dict[str, Any]]:
        result_items = []
        for name, value in api_results[0].items():
            result_items.append({
                "name": name,
                "value": value,
            })
        return result_items

    def _get_routeros_version(self) -> RouterOSVersion:
        call = self.api.path(
            "/system/resource"
        )
        results = tuple(call)
        result: Dict[str, str] = results[0]
        # version: 7.8 (stable)
        version_string = result["version"].partition(" ")[0]
        return RouterOSVersion(version_string)

    def connect_api(self) -> librouteros.api.Api:
        if self._api is None:
            self._api = self._connect_api()

        return self._api

    @classmethod
    def parse_routeros_date(cls, date_string: str) -> date:
        logger.debug(f"Parsing date string {date_string}")
        # Try iso date
        # Looks like they have switched date format in 7.11
        try:
            return date.fromisoformat(date_string)
        except ValueError as e:
            logger.debug(f"'{date_string}' is not in iso format: {str(e)}")

        # Try US date
        m = cls.regex_date.match(date_string)
        if m:
            return date(
                year=int(m.group("year")),
                month=cls.month_mapping[m.group("month").lower()],
                day=int(m.group("day"))
            )

        raise ValueError("Unable to parse datetime string")

    @classmethod
    def parse_routeros_date_time(cls, date_string: str, time_string: str) -> datetime:
        parsed_date = cls.parse_routeros_date(date_string=date_string)
        parsed_time = cls.parse_routeros_time(time_string=time_string)

        return datetime.combine(parsed_date, parsed_time)

    @classmethod
    def parse_routeros_datetime(cls, datetime_string: str) -> datetime:
        logger.debug(f"Parsing datetime string {datetime_string}")

        try:
            return datetime.fromisoformat(datetime_string)
        except ValueError as e:
            logger.debug(f"'{datetime_string}' is not in iso format: {str(e)}")

        m = cls.regex_datetime.match(datetime_string)
        if not m:
            raise ValueError("Unable to parse datetime string")

        return datetime(
            year=int(m.group("year")),
            month=cls.month_mapping[m.group("month").lower()],
            day=int(m.group("day")),
            hour=int(m.group("hour")),
            minute=int(m.group("minute")),
            second=int(m.group("second"))
        )

    @staticmethod
    def parse_routeros_speed(value_string: str) -> int:
        factors = {
            "": 1,
            "K": 1000,
            "M": 1000 * 1000,
            "G": 1000 * 1000 * 1000,
        }

        m = re.compile(r"(?P<value>\d+)(?P<factor>[A-Z]*)bps").match(value_string)
        if not m:
            raise ValueError(f"Unable to parse speed string: '{value_string}'")

        factor = factors.get(m.group("factor"))

        if factor is None:
            raise ValueError(f"Unable to parse element '{m.group()}' of speed string: '{value_string}'")

        return int(m.group("value")) * factor

    @classmethod
    def parse_routeros_time(cls, time_string: str) -> time:
        m = cls.regex_time.match(time_string)
        if not m:
            raise ValueError("Unable to parse datetime string")

        return time(
            hour=int(m.group("hour")),
            minute=int(m.group("minute")),
            second=int(m.group("second"))
        )

    @staticmethod
    def parse_routeros_time_duration(time_string: str) -> float:
        factors: Dict[str, Union[int, Decimal]] = {
            "us": Decimal(1e-6),
            "ms": Decimal(0.001),
            "s": 1,
            "m": 60,
            "h": 60 * 60,
            "d": 24 * 60 * 60,
            "w": 7 * 24 * 60 * 60,
        }

        value_is_negativ = time_string.startswith("-")

        seconds = Decimal(0)
        for m in re.compile(r"(?P<value>\d+)(?P<type>[a-z]+)").finditer(time_string):
            factor = factors.get(m.group("type"))
            if factor is None:
                raise ValueError(f"Unable to parse element '{m.group()}' of time string: '{time_string}'")
            seconds += int(m.group("value")) * factor

        seconds_float = float(round(seconds, 6))

        if value_is_negativ:
            return -seconds_float
        return seconds_float

    @staticmethod
    def prepare_override_values(override_values: List[str]) -> Dict[str, str]:
        results: Dict[str, str] = {}
        for override_value in override_values:
            name, _, value = override_value.partition(":")
            if value is None or value == "":
                logger.warning(f"Unable to parse override value for {name}")
            results[name] = value
        return results

    @staticmethod
    def prepare_thresholds(thresholds: List[str]) -> Dict[str, str]:
        results: Dict[str, str] = {}
        for threshold in thresholds:
            name, _, value = threshold.partition(":")
            if value is None or value == "":
                logger.warning(f"Unable to parse threshold for {name}")
            results[name] = value
        return results

    @staticmethod
    def prepare_regex_thresholds(thresholds: List[str]) -> Dict[re.Pattern, str]:
        results: Dict[re.Pattern, str] = {}
        for threshold in thresholds:
            name, _, value = threshold.partition(":")
            if value is None or value == "":
                logger.warning(f"Unable to parse threshold for {name}")
            results[re.compile(name)] = value
        return results

    def get_routeros_select_keys(self) -> List[librouteros.query.Key]:
        keys = []
        for metric_value in self._routeros_metric_values:
            keys.append(librouteros.query.Key(metric_value["name"]))
        return keys

    def get_routeros_metric_item(
        self, api_result: Dict[str, Any], name_prefix="", cookie=None
    ) -> List[nagiosplugin.Metric]:
        metrics = []

        elapsed_seconds = None
        if cookie:
            last_time_tuple = cookie.get("last_time")
            if isinstance(last_time_tuple, (list, tuple)):
                last_time = datetime(*last_time_tuple[0:6])
                delta_time = self.current_time - last_time
                elapsed_seconds = delta_time.total_seconds()

        #
        for metric_value in self._routeros_metric_values:
            metric_value_name = metric_value["name"]
            if metric_value_name not in api_result:
                if not metric_value.get("missing_ok", False):
                    self._check.results.add(
                        nagiosplugin.Result(
                            nagiosplugin.state.Unknown,
                            hint=f"Required metric with name '{metric_value_name}' not found in response"
                        )
                    )
                continue

            value = api_result[metric_value_name]
            metric_value_type = metric_value.get("type")
            if callable(metric_value_type):
                try:
                    value = metric_value_type(value)
                except ValueError as e:
                    logger.warning(f"Error parsing value with name {metric_value_name}", exc_info=True)
                    raise e

            value_factor = metric_value.get("factor")
            if value_factor is not None:
                if isinstance(value, bool):
                    logger.warning(f"Unable to use factor with bool metric {metric_value_name}", exc_info=True)
                    raise ValueError(f"Unable to use factor with bool metric {metric_value_name}")
                value = value * value_factor

            extra_kwargs = {}
            for n in ("min", "max", "uom"):
                if n in metric_value:
                    extra_kwargs[n] = metric_value[n]

            dst_value_name = metric_value.get("dst_value_name")
            if isinstance(dst_value_name, str):
                api_result[dst_value_name] = value

            if not metric_value.get("no_metric"):
                metrics.append(
                    nagiosplugin.Metric(
                        name=name_prefix + metric_value.get("dst", metric_value_name),
                        value=value,
                        **extra_kwargs,
                    )
                )

            if metric_value.get("rate"):
                try:
                    rate_value = self._calc_rate(
                        cookie=cookie,
                        name=metric_value_name,
                        cur_value=value,
                        elapsed_seconds=elapsed_seconds,
                        factor=metric_value.get("rate_factor", 1)
                    )
                    metrics.append(
                        nagiosplugin.Metric(
                            name=f"{name_prefix}{metric_value.get('dst', metric_value_name)}_rate",
                            value=rate_value,
                            uom=metric_value.get("rate_uom"),
                            min=metric_value.get("rate_min"),
                            max=metric_value.get("rate_max"),
                        )
                    )
                except MissingValue as e:
                    logger.debug(f"{e}", exc_info=e)

        if cookie:
            cookie["last_time"] = self.current_time.timetuple()

        return metrics

    def get_routeros_metrics(
        self, api_results: Union[List[Dict[str, Any]], Dict[str, Any]], name_prefix="", cookie=None
    ) -> List[nagiosplugin.Metric]:
        def get_api_result_by_name(api_results, name):
            for item in api_results:
                if name == item["name"]:
                    return item
            return None

        def new_api_result_item(api_results, item, ignore_if_exist=True):
            tmp_item = get_api_result_by_name(api_results, item["name"])
            if tmp_item is not None:
                api_results.append(item)
                return api_results

            if ignore_if_exist:
                return api_results

            raise ValueError("Duplicated entry")

        metrics = []

        elapsed_seconds = None
        if cookie:
            last_time_tuple = cookie.get("last_time")
            if isinstance(last_time_tuple, (list, tuple)):
                last_time = datetime(*last_time_tuple[0:6])
                delta_time = self.current_time - last_time
                elapsed_seconds = delta_time.total_seconds()

        if isinstance(api_results, dict):
            logger.debug(f"Extracted values {pformat(api_results)}")
            api_results = self._convert_v6_list_to_v7(api_results=api_results)

        #
        for metric_value in self._routeros_metric_values:
            metric_value_name = metric_value["name"]
            api_result = get_api_result_by_name(api_results, metric_value_name)

            if api_result is None:
                if not metric_value.get("missing_ok", False):
                    self._check.results.add(
                        nagiosplugin.Result(
                            nagiosplugin.state.Unknown,
                            hint=f"Required metric with name '{metric_value_name}' not found in response"
                        )
                    )
                continue

            value = api_result["value"]
            metric_value_type = metric_value.get("type")
            if callable(metric_value_type):
                try:
                    value = metric_value_type(value)
                except ValueError as e:
                    logger.warning(f"Error parsing value with name {metric_value_name}", exc_info=True)
                    raise e

            value = value * metric_value.get("factor", 1)

            extra_kwargs = {}
            for n in ("min", "max", "uom"):
                if n in metric_value:
                    extra_kwargs[n] = metric_value[n]

            dst_value_name = metric_value.get("dst_value_name")
            if isinstance(dst_value_name, str):
                api_results = new_api_result_item(
                    api_results,
                    {
                        "name": dst_value_name,
                        "value": value,
                    },
                    ignore_if_exist=True
                )

            if not metric_value.get("no_metric"):
                metrics.append(
                    nagiosplugin.Metric(
                        name=name_prefix + metric_value.get("dst", metric_value_name),
                        value=value,
                        **extra_kwargs,
                    )
                )

            if metric_value.get("rate"):
                try:
                    rate_value = self._calc_rate(
                        cookie=cookie,
                        name=metric_value_name,
                        cur_value=value,
                        elapsed_seconds=elapsed_seconds,
                        factor=metric_value.get("rate_factor", 1)
                    )
                    metrics.append(
                        nagiosplugin.Metric(
                            name=f"{name_prefix}{metric_value.get('dst', metric_value_name)}_rate",
                            value=rate_value,
                            uom=metric_value.get("rate_uom"),
                            min=metric_value.get("rate_min"),
                            max=metric_value.get("rate_max"),
                        )
                    )
                except MissingValue as e:
                    logger.debug(f"{e}", exc_info=e)

        if cookie:
            cookie["last_time"] = self.current_time.timetuple()

        return metrics
