# ======================================================================
# Copyright 2018 Julien LE CLEACH
#
# 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 math
from distutils.util import strtobool
from typing import Optional, Tuple
from urllib.parse import quote

from supvisors.process import ProcessStatus
from supvisors.ttypes import StartingStrategies, NameList
from supvisors.utils import get_bit, set_bit
from .webutils import SUPVISORS_PAGE, error_message

# form parameters
SERVER_URL = 'SERVER_URL'
SERVER_PORT = 'SERVER_PORT'
PATH_TRANSLATED = 'PATH_TRANSLATED'

IDENTIFIER = 'ident'  # navigation
APPLI = 'appname'  # navigation

ACTION = 'action'
NAMESPEC = 'namespec'   # used for actions
PROCESS = 'processname'  # used to tail (but also to display statistics)

PERIOD = 'period'
STRATEGY = 'strategy'
CPU = 'cpuid'
NIC = 'nic'
DISK_STATS = 'diskstats'
PARTITION = 'partition'
DEVICE = 'device'
AUTO = 'auto'  # auto-refresh
LIMIT = 'limit'

MESSAGE = 'message'
GRAVITY = 'gravity'

APP_SHRINK_EXPAND = 'ashex'
PROC_SHRINK_EXPAND = 'pshex'

# response parameters
HEADERS = 'headers'
LOCATION = 'Location'


class ViewContext:
    """ Class used to retrieve the parameters selected on the web page.
    It is also used to format href in html pages. """

    def __init__(self, context):
        """ Define attributes for statistics selection. """
        # store the HTML context
        self.http_context = context
        # keep references to Supvisors instance
        self.supvisors = context.supervisord.supvisors
        self.logger = self.supvisors.logger
        # keep reference to the local identifier
        self.local_identifier = self.supvisors.mapper.local_identifier
        # initialize parameters
        self.parameters = {}
        self.store_message = None
        self.redirect = False
        # extract parameters from context
        self.update_strategy()
        self.update_auto_refresh()
        self.update_identifier()
        self.update_application_name()
        self.update_process_name()
        self.update_namespec()
        self.update_application_shrink_expand()
        self.update_process_shrink_expand()
        self.update_period()
        self.update_cpu_id()
        self.update_nic_name()
        self.update_disk_stats_choice()
        self.update_partition_name()
        self.update_device_name()

    # information extracted
    @property
    def strategy(self) -> StartingStrategies:
        return StartingStrategies[self.parameters[STRATEGY]]

    @property
    def auto_refresh(self) -> bool:
        return self.parameters[AUTO]

    @property
    def identifier(self) -> str:
        return self.parameters[IDENTIFIER]

    @property
    def application_name(self) -> str:
        return self.parameters[APPLI]

    @property
    def process_name(self) -> str:
        return self.parameters[PROCESS]

    @process_name.setter
    def process_name(self, proc_name: str) -> None:
        self.parameters[PROCESS] = proc_name

    @property
    def namespec(self) -> str:
        return self.parameters[NAMESPEC]

    @property
    def application_shex(self) -> str:
        return self.parameters[APP_SHRINK_EXPAND]

    @property
    def process_shex(self) -> str:
        return self.parameters[PROC_SHRINK_EXPAND]

    @property
    def period(self) -> float:
        return self.parameters[PERIOD]

    @property
    def cpu_id(self) -> int:
        return self.parameters[CPU]

    @property
    def nic_name(self) -> str:
        return self.parameters[NIC]

    @property
    def device(self) -> str:
        return self.parameters[DEVICE]

    @property
    def partition(self) -> str:
        return self.parameters[PARTITION]

    @property
    def disk_stats(self) -> str:
        return self.parameters[DISK_STATS]

    # simple extraction from context form
    @property
    def action(self) -> str:
        """ Extract action requested in context form. """
        return self.http_context.form.get(ACTION)

    @property
    def message(self) -> str:
        """ Extract message text from context form. """
        return self.http_context.form.get(MESSAGE)

    @property
    def gravity(self) -> str:
        """ Extract message gravity from context form. """
        return self.http_context.form.get(GRAVITY)

    def set_message_default(self, message: str, gravity: str):
        """ Set the message and gravity if there is no existing message. """
        if not self.message:
            self.http_context.form[GRAVITY] = gravity
            self.http_context.form[MESSAGE] = message

    # complex extraction from context form
    def update_period(self) -> None:
        """ Extract period from context. """
        default_value = next(iter(self.supvisors.options.stats_periods))
        self._update_float(PERIOD, self.supvisors.options.stats_periods, default_value)

    def update_strategy(self) -> None:
        """ Extract starting strategy from context. """
        self._update_string(STRATEGY, [x.name for x in StartingStrategies],
                            self.supvisors.options.starting_strategy.name)

    def update_auto_refresh(self) -> None:
        """ Extract auto refresh from context. """
        # assign value found or default
        self._update_boolean(AUTO, False)

    def update_identifier(self) -> None:
        """ Extract identifier from context. """
        # assign value found or default
        self._update_string(IDENTIFIER, self.supvisors.mapper.instances, self.local_identifier)

    def update_application_name(self) -> None:
        """ Extract application name from context. """
        self._update_string(APPLI, list(self.supvisors.context.applications.keys()))

    def update_process_name(self) -> None:
        """ Extract process name from context.
        ApplicationView may select a process unknown to this Supvisors instance. """
        status = self.supvisors.context.instances[self.parameters[IDENTIFIER]]
        # consider processes running on this Supvisors instance + supervisord
        running_processes = [x.namespec for x in status.running_processes()]
        running_processes.append('supervisord')
        self._update_string(PROCESS, running_processes)

    def update_namespec(self) -> None:
        """ Extract namespec from context. """
        value = None
        str_value = self.http_context.form.get(NAMESPEC)
        if str_value:
            # check that value is known to Supvisors
            if self.supvisors.context.is_namespec(str_value):
                value = str_value
            else:
                self.store_message = error_message(f'Incorrect {NAMESPEC}: {str_value}')
        # assign value found or default
        self.logger.trace(f'ViewContext.update_namespec: {NAMESPEC} set to {value}')
        self.parameters[NAMESPEC] = value

    def update_cpu_id(self) -> None:
        """ Extract CPU id from context. """
        self._update_integer(CPU, list(range(self.get_nb_cores(self.local_identifier) + 1)))

    def update_nic_name(self) -> None:
        """ Extract network interface name from context.
        Only the HostInstanceView displays interface data, so it's local. """
        stats_instance = self.get_instance_stats()
        interfaces = stats_instance.net_io.keys() if stats_instance else []
        default_value = next(iter(interfaces), None)
        self._update_string(NIC, interfaces, default_value)

    def update_disk_stats_choice(self) -> None:
        """ Extract the disk stats choice. """
        self._update_string(DISK_STATS, ['usage', 'io'], 'io')

    def update_partition_name(self) -> None:
        """ Extract interface name from context.
        Only the HostInstanceView displays interface data, so it's local. """
        stats_instance = self.get_instance_stats()
        partitions = stats_instance.disk_usage.keys() if stats_instance else []
        default_value = next(iter(partitions), None)
        self._update_string(PARTITION, partitions, default_value)

    def update_device_name(self) -> None:
        """ Extract interface name from context.
        Only the HostInstanceView displays interface data, so it's local. """
        stats_instance = self.get_instance_stats()
        devices = stats_instance.disk_io.keys() if stats_instance else []
        default_value = next(iter(devices), None)
        self._update_string(DEVICE, devices, default_value)

    @staticmethod
    def _get_default_shex(nb_items: int, expanded: bool) -> bytearray:
        """ Get a default shex bytearray filled with 1 if expanded.

        :param nb_items: the number of items in the shex.
        :param expanded: a status telling if the bytearray should be filled with 0 or 1.
        :return: the application shex bytearray.
        """
        base_value = 0xff if expanded else 0
        return bytearray([base_value] * math.ceil(nb_items / 8))

    def get_default_application_shex(self, expanded: bool) -> bytearray:
        """ Get a default application shex bytearray filled with 1 if expanded.

        :param expanded: a status telling if the bytearray should be filled with 0 or 1.
        :return: the application shex bytearray.
        """
        nb_applications = len(self.supvisors.context.applications)
        return ViewContext._get_default_shex(nb_applications, expanded)

    def get_default_process_shex(self, application_name: str, expanded: bool) -> bytearray:
        """ Get a default process shex bytearray filled with 1 if expanded, for the considered application.

        :param application_name: the application name to get the processes from.
        :param expanded: a status telling if the bytearray should be filled with 0 or 1.
        :return: the process shex bytearray.
        """
        nb_processes = len(self.supvisors.context.applications[application_name].processes)
        return ViewContext._get_default_shex(nb_processes, expanded)

    def update_application_shrink_expand(self):
        """ Extract process display choices from context.
        By default, all are displayed. """
        ba = self.get_default_application_shex(True)
        self._update_shex(APP_SHRINK_EXPAND, ba)

    def update_process_shrink_expand(self):
        """ Extract instance process display choices from context.
        By default, all are hidden. """
        if self.application_name:
            ba = self.get_default_process_shex(self.application_name, False)
            self._update_shex(PROC_SHRINK_EXPAND, ba)

    def _update_shex(self, param: str, default_ba: bytearray) -> None:
        """ Extract shex from context. """
        # extract mask from context
        str_value = self.http_context.form.get(param)
        if str_value:
            # check that value has correct format (only hex and size twice the size of the bytearray)
            try:
                value = bytearray.fromhex(str_value)
            except ValueError:
                self.logger.error(f'ViewContext._update_shex: non-hexadecimal {param}')
            else:
                if len(default_ba) != len(value):
                    self.logger.error(f'ViewContext._update_shex: {param} does not fit with the number of items')
                else:
                    default_ba = value
        self.logger.debug(f'ViewContext._update_shex: {param} set to {default_ba.hex()}')
        self.parameters[param] = default_ba.hex()

    def _update_string(self, param: str, check_list: NameList, default_value: str = None):
        """ Extract information from context based on allowed values in check_list. """
        value = default_value
        str_value = self.http_context.form.get(param)
        if str_value:
            # check that value is known to Supvisors
            if str_value in check_list:
                value = str_value
            else:
                self.store_message = error_message(f'Incorrect {param}: {str_value}')
        # assign value found or default
        self.logger.trace(f'ViewContext._update_string: {param} set to {value}')
        self.parameters[param] = value

    def _update_integer(self, param, check_list, default_value=0):
        """ Extract information from context and convert to integer based on allowed values in check_list. """
        value = default_value
        str_value = self.http_context.form.get(param)
        if str_value:
            try:
                int_value = int(str_value)
            except ValueError:
                self.store_message = error_message(f'{param} is not an integer: {str_value}')
            else:
                # check that int_value is defined in check list
                if int_value in check_list:
                    value = int_value
                else:
                    self.store_message = error_message(f'Incorrect {param}: {int_value}')
        # assign value found or default
        self.logger.trace(f'ViewContext._update_integer: {param} set to {value}')
        self.parameters[param] = value

    def _update_float(self, param, check_list, default_value=0.0):
        """ Extract information from context and convert to float based on allowed values in check_list. """
        value = default_value
        str_value = self.http_context.form.get(param)
        if str_value:
            try:
                float_value = float(str_value)
            except ValueError:
                self.store_message = error_message(f'{param} is not a float: {str_value}')
            else:
                # check that float_value is defined in check list
                if float_value in check_list:
                    value = float_value
                else:
                    self.store_message = error_message(f'Incorrect {param}: {float_value}')
        # assign value found or default
        self.logger.trace(f'ViewContext._update_float: {param} set to {value}')
        self.parameters[param] = value

    def _update_boolean(self, param, default_value=False):
        """ Extract information from context and convert to boolean based on allowed values in check_list. """
        value = default_value
        str_value = self.http_context.form.get(param)
        if str_value:
            try:
                value = strtobool(str_value)
            except ValueError:
                self.store_message = error_message(f'{param} is not a boolean-like: {str_value}')
        # assign value found or default
        self.logger.trace(f'ViewContext._update_boolean: {param} set to {value}')
        self.parameters[param] = value

    # URL formatting
    def url_parameters(self, reset_shex, **kwargs):
        """ Return the list of parameters for a URL. """
        parameters = dict(self.parameters, **kwargs)
        if reset_shex:
            del parameters[APP_SHRINK_EXPAND]
        return '&'.join([f'{key}={quote(str(value))}'
                         for key, value in sorted(parameters.items()) if value])

    def format_url(self, identifier: str, page: str, **kwargs):
        """ Format URL from parameters. """
        netloc = ''
        # build network location if identifier is provided
        if identifier:
            instance = self.supvisors.mapper.instances[identifier]
            netloc = f'http://{quote(instance.host_id)}:{instance.http_port}/'
        # shex must be reset if the Supvisors instance changes
        local_identifier = not identifier or identifier == self.local_identifier
        # build URL from netloc, page and attributes
        return f'{netloc}{page}?{self.url_parameters(not local_identifier, **kwargs)}'

    def fire_message(self) -> None:
        """ Set message in context response to be displayed at next refresh. """
        if self.store_message:
            args = {MESSAGE: self.store_message[1], GRAVITY: self.store_message[0]}
            form = self.http_context.form
            # if redirect requested, go back to main page
            path_translated = '/' + SUPVISORS_PAGE if self.redirect else form[PATH_TRANSLATED]
            location = f'{form[SERVER_URL]}{path_translated}?{self.url_parameters(False, **args)}'
            self.http_context.response[HEADERS][LOCATION] = location

    # Statistics
    def get_nb_cores(self, identifier: str) -> int:
        """ Get the number of processors of the host where the Supvisors instance is running. """
        # 2 chances to get the value
        nb_cores = self.supvisors.host_compiler.get_nb_cores(identifier)
        if not nb_cores:
            nb_cores = self.supvisors.process_compiler.get_nb_cores(identifier)
        return nb_cores

    def get_node_characteristics(self):
        """ Get the node characteristics from the stats collector. """
        if self.supvisors.stats_collector:
            return self.supvisors.stats_collector.node_info
        return None

    def get_instance_stats(self, identifier: str = None):
        """ Get the statistics structure related to the identifier and the period selected.
        If no identifier is specified, local identifier is used. """
        stats_identifier = identifier or self.local_identifier
        period = self.parameters.get(PERIOD)
        return self.supvisors.host_compiler.get_stats(stats_identifier, period)

    def get_process_stats(self, namespec: str, identifier: str):
        """ Get the statistics structure related to the process and the period selected.
        Get also the number of cores available where this Supvisors instance runs (useful for process CPU IRIX mode).
        """
        # get the number of cores for Solaris mode
        nb_cores = self.get_nb_cores(identifier)
        # return the process statistics for this process
        period = self.parameters.get(PERIOD)
        stats_instance = self.supvisors.process_compiler.get_stats(namespec, identifier, period)
        return nb_cores, stats_instance

    def get_process_status(self, namespec: str = None) -> Optional[ProcessStatus]:
        """ Get the ProcessStatus instance related to the process named namespec.
        If none specified, the form namespec is used. """
        namespec = namespec or self.namespec
        if namespec:
            try:
                return self.supvisors.context.get_process(namespec)
            except KeyError:
                self.logger.debug(f'ViewContext.get_process_status: failed to get ProcessStatus from {namespec}')

    # shex access
    def get_application_shex(self, application_name: str) -> Tuple[bool, str]:
        """ Get the expand / shrink value of the application and the shex string to invert it.

        :param application_name: the application name.
        :return: the application shex and the inverted shex.
        """
        ba = bytearray.fromhex(self.application_shex)
        # get the index of the application in context
        idx = sorted(self.supvisors.context.applications).index(application_name)
        # get application shex value
        application_shex = bool(get_bit(ba, idx))
        # get new shex with inverted value for application
        set_bit(ba, idx, not application_shex)
        return application_shex, ba.hex()

    def get_process_shex(self, process_name: str) -> Tuple[bool, str]:
        """ Get the expand / shrink value of the process and the shex string to invert it.

        :param process_name: the process name.
        :return: the process shex and the inverted shex.
        """
        ba = bytearray.fromhex(self.process_shex)
        # get the index of the application in context
        idx = sorted(self.supvisors.context.applications[self.application_name].processes).index(process_name)
        # get application shex value
        process_shex = bool(get_bit(ba, idx))
        # get new shex with inverted value for application
        set_bit(ba, idx, not process_shex)
        return process_shex, ba.hex()

    @staticmethod
    def cpu_id_to_string(idx):
        """ Get a printable form of cpu index. """
        return f'{idx - 1}' if idx > 0 else 'all'
