#
# Copyright 2021 Splunk Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import logging
import re
import traceback
from abc import abstractmethod

from jsonschema import ValidationError, validate
from munch import munchify

from ..common.log import get_cc_logger
from ..common.util import is_true, is_valid_bool, is_valid_port, load_json_file
from ..core.exceptions import ConfigException
from ..core.ext import lookup_method
from ..core.models import (
    BasicAuthorization,
    Checkpoint,
    Condition,
    DictToken,
    IterationMode,
    Processor,
    RequestParams,
    Task,
)

_logger = get_cc_logger()

_PROXY_TYPES = ["http", "socks4", "socks5", "http_no_tunnel"]
_AUTH_TYPES = {"basic_auth": BasicAuthorization}

_LOGGING_LEVELS = {
    "DEBUG": logging.DEBUG,
    "INFO": logging.INFO,
    "WARNING": logging.WARNING,
    "ERROR": logging.ERROR,
    "FATAL": logging.FATAL,
    "CRITICAL": logging.CRITICAL,
}

# FIXME Make this configurable
_DEFAULT_LOG_LEVEL = "INFO"


class CloudConnectConfigLoader:
    """The Base cloud connect configuration loader"""

    @staticmethod
    def _get_schema_from_file(schema_file):
        """Load JSON based schema definition from schema file path.
        :return: A `dict` contains schema.
        """
        try:
            return load_json_file(schema_file)
        except:
            raise ConfigException(
                "Cannot load schema from file {}: {}".format(
                    schema_file, traceback.format_exc()
                )
            )

    @abstractmethod
    def load(self, definition, schema_file, context):
        pass


class CloudConnectConfigLoaderV1(CloudConnectConfigLoader):
    @staticmethod
    def _render_from_dict(source, ctx):
        rendered = DictToken(source).render(ctx)

        return {k: v.strip() if isinstance(v, str) else v for k, v in rendered.items()}

    def _load_proxy(self, candidate, variables):
        """
        Render and validate proxy setting with given variables.
        :param candidate: raw proxy setting as `dict`
        :param variables: variables to render template in proxy setting.
        :return: A `dict` contains rendered proxy setting.
        """
        if not candidate:
            return {}

        proxy = self._render_from_dict(candidate, variables)

        enabled = proxy.get("enabled", "0")
        if not is_valid_bool(enabled):
            raise ValueError(f'Proxy "enabled" expect to be bool type: {enabled}')

        proxy["enabled"] = is_true(enabled)

        host, port = proxy.get("host"), proxy.get("port")

        if host or port:
            if not host:
                raise ValueError('Proxy "host" must not be empty')

            if not is_valid_port(port):
                raise ValueError(
                    'Proxy "port" expect to be in range [1,65535]: %s' % port
                )

        # proxy type default to 'http'
        proxy_type = proxy.get("type")
        proxy_type = proxy_type.lower() if proxy_type else "http"
        if proxy_type not in _PROXY_TYPES:
            raise ValueError(
                'Proxy "type" expect to be one of [{}]: {}'.format(
                    ",".join(_PROXY_TYPES), proxy_type
                )
            )
        else:
            proxy["type"] = proxy_type

        # proxy rdns default to '0'
        proxy_rdns = proxy.get("rdns", "0")
        if not is_valid_bool(proxy_rdns):
            raise ValueError(f'Proxy "rdns" expect to be bool type: {proxy_rdns}')
        else:
            proxy["rdns"] = is_true(proxy_rdns)

        return proxy

    @staticmethod
    def _get_log_level(level_name):
        if level_name:
            level_name = level_name.upper().strip()

            for k, v in _LOGGING_LEVELS.items():
                if k.startswith(level_name):
                    return v

        _logger.warning(
            'The log level "%s" is invalid, set it to default: "%s"',
            level_name,
            _DEFAULT_LOG_LEVEL,
        )

        return _LOGGING_LEVELS[_DEFAULT_LOG_LEVEL]

    def _load_logging(self, log_setting, variables):
        logger = self._render_from_dict(log_setting, variables)

        logger["level"] = self._get_log_level(logger.get("level"))

        return logger

    def _load_global_setting(self, candidate, variables):
        """
        Load and render global setting with variables.
        :param candidate: Global setting as a `dict`
        :param variables: variables from context to render setting
        :return: A `Munch` object
        """
        candidate = candidate or {}
        proxy_setting = self._load_proxy(candidate.get("proxy"), variables)
        log_setting = self._load_logging(candidate.get("logging"), variables)

        return munchify({"proxy": proxy_setting, "logging": log_setting})

    @staticmethod
    def _load_authorization(candidate):
        if candidate is None:
            return None
        auth_type = candidate["type"].lower()

        if auth_type not in _AUTH_TYPES:
            raise ValueError(
                "Auth type expect to be one of [{}]: {}".format(
                    ",".join(list(_AUTH_TYPES.keys())), auth_type
                )
            )
        return _AUTH_TYPES[auth_type](candidate["options"])

    def _load_options(self, options):
        return RequestParams(
            auth=self._load_authorization(options.get("auth")),
            url=options["url"],
            method=options.get("method", "GET"),
            header=options.get("headers", {}),
            body=options.get("body", {}),
        )

    @staticmethod
    def _validate_method(method):
        if lookup_method(method) is None:
            raise ValueError(f"Unimplemented method: {method}")

    def _parse_tasks(self, raw_tasks):
        tasks = []
        for item in raw_tasks:
            self._validate_method(item["method"])
            tasks.append(Task(item["input"], item["method"], item.get("output")))
        return tasks

    def _parse_conditions(self, raw_conditions):
        conditions = []
        for item in raw_conditions:
            self._validate_method(item["method"])
            conditions.append(Condition(item["input"], item["method"]))
        return conditions

    @staticmethod
    def _load_checkpoint(checkpoint):
        if not checkpoint:
            return None
        return Checkpoint(checkpoint.get("namespace", []), checkpoint["content"])

    def _load_iteration_mode(self, iteration_mode):
        count = iteration_mode.get("iteration_count", "0")
        try:
            iteration_count = int(count)
        except ValueError:
            raise ValueError('"iteration_count" must be an integer: %s' % count)

        stop_conditions = self._parse_conditions(iteration_mode["stop_conditions"])

        return IterationMode(
            iteration_count=iteration_count, conditions=stop_conditions
        )

    def _load_processor(self, processor):
        skip_conditions = self._parse_conditions(processor.get("skip_conditions", []))
        pipeline = self._parse_tasks(processor.get("pipeline", []))
        return Processor(skip_conditions=skip_conditions, pipeline=pipeline)

    def _load_request(self, request):
        options = self._load_options(request["request"])

        pre_process = self._load_processor(request.get("pre_process", {}))
        post_process = self._load_processor(request["post_process"])
        checkpoint = self._load_checkpoint(request.get("checkpoint"))
        iteration_mode = self._load_iteration_mode(request["iteration_mode"])

        return munchify(
            {
                "request": options,
                "pre_process": pre_process,
                "post_process": post_process,
                "checkpoint": checkpoint,
                "iteration_mode": iteration_mode,
            }
        )

    def load(self, definition, schema_file, context):
        """Load cloud connect configuration from a `dict` and validate
        it with schema and global settings will be rendered.
        :param schema_file: Schema file location used to validate config.
        :param definition: A dictionary contains raw configs.
        :param context: variables to render template in global setting.
        :return: A `Munch` object.
        """
        try:
            validate(definition, self._get_schema_from_file(schema_file))
        except ValidationError:
            raise ConfigException(
                "Failed to validate interface with schema: {}".format(
                    traceback.format_exc()
                )
            )

        try:
            global_settings = self._load_global_setting(
                definition.get("global_settings"), context
            )

            requests = [self._load_request(item) for item in definition["requests"]]

            return munchify(
                {
                    "meta": munchify(definition["meta"]),
                    "tokens": definition["tokens"],
                    "global_settings": global_settings,
                    "requests": requests,
                }
            )
        except Exception as ex:
            error = "Unable to load configuration: %s" % str(ex)
            _logger.exception(error)
            raise ConfigException(error)


_loader_and_schema_by_version = {
    r"1\.0\.0": (CloudConnectConfigLoaderV1, "schema_1_0_0.json"),
}


def get_loader_by_version(version):
    """Instantiate a configuration loader on basis of a given version.
    A `ConfigException` will raised if the version is not supported.
    :param version: Version to lookup config loader.
    :return: A config loader.
    """
    for support_version in _loader_and_schema_by_version:
        if re.match(support_version, version):
            loader_cls, schema = _loader_and_schema_by_version[support_version]
            return loader_cls(), schema

    raise ConfigException(
        "Unsupported schema version {}, current supported"
        " versions should match these regex [{}]".format(
            version, ",".join(_loader_and_schema_by_version)
        )
    )
