import json
import logging
import os
import re
import shutil
from datetime import datetime
from typing import NamedTuple, Optional

from auth.authorization import Authorizer
from config.exceptions import InvalidConfigException
from model import script_config
from model.model_helper import InvalidFileException
from model.script_config import get_sorted_config, ShortConfig, create_failed_short_config
from utils import os_utils, file_utils, process_utils, custom_json, custom_yaml
from utils.file_utils import to_filename
from utils.process_utils import ProcessInvoker
from utils.string_utils import is_blank, strip

SCRIPT_EDIT_CODE_MODE = 'new_code'
SCRIPT_EDIT_UPLOAD_MODE = 'upload_script'
SCRIPT_EDIT_PATH_MODE = 'new_path'

SCRIPT_PATH_FIELD = 'script_path'
WORKING_DIR_FIELD = 'working_directory'

LOGGER = logging.getLogger('config_service')

class ConfigSearchResult(NamedTuple):
    short_config: ShortConfig
    path: str
    config_object: any


def _script_name_to_file_name(script_name):
    filename = _escape_characters_in_filename(script_name)
    return filename + '.json'


def _escape_characters_in_filename(script_name):
    escaped = re.sub('[\\s/]+', '_', script_name).strip("_")
    return to_filename(escaped)


def _preprocess_incoming_config(config):
    name = config.get('name')
    if is_blank(name):
        raise InvalidConfigException('Script name is required')
    config['name'] = name.strip()


def _create_archive_filename(filename):
    current_datetime = datetime.now()
    formatted_datetime = current_datetime.strftime('%Y%m%d%H%M%S')
    return f"{formatted_datetime}_{filename}.deleted"


class ConfigService:
    def __init__(
            self,
            authorizer,
            conf_folder,
            group_scripts_by_folder: bool,
            process_invoker: ProcessInvoker) -> None:

        self._authorizer = authorizer  # type: Authorizer
        self._script_configs_folder = os.path.join(conf_folder, 'runners')
        self._scripts_folder = os.path.join(conf_folder, 'scripts')
        self._scripts_deleted_folder = os.path.join(conf_folder, 'deleted')
        self._process_invoker = process_invoker
        self._group_scripts_by_folder = group_scripts_by_folder

        file_utils.prepare_folder(self._script_configs_folder)
        file_utils.prepare_folder(self._scripts_deleted_folder)

    def load_config(self, name, user):
        self._check_admin_access(user)

        search_result = self._find_config(name, user)

        if search_result is None:
            return None

        (short_config, path, config_object) = search_result

        if config_object.get('name') is None:
            config_object['name'] = short_config.name

        if not self._can_edit_script(user, short_config):
            raise ConfigNotAllowedException(str(user) + ' has no admin access to ' + short_config.name)

        return {'config': config_object, 'filename': os.path.basename(path)}

    def create_config(self, user, config, uploaded_script):
        self._check_admin_access(user)
        _preprocess_incoming_config(config)

        name = config['name']

        search_result = self._find_config(name, user)
        if search_result is not None:
            raise InvalidConfigException('Another config with the same name already exists')

        self._preprocess_script_fields(config, None, uploaded_script, user)

        path = os.path.join(self._script_configs_folder, _script_name_to_file_name(name))
        unique_path = file_utils.create_unique_filename(path, 100)

        LOGGER.info('Creating new script config "' + name + '" in ' + unique_path)
        self._save_config(config, unique_path)

    def update_config(self, user, config, filename, uploaded_script):
        self._check_admin_access(user)

        _preprocess_incoming_config(config)

        if is_blank(filename):
            raise InvalidConfigException('Script filename should be specified')

        original_file_path = os.path.join(self._script_configs_folder, filename)

        if not os.path.exists(original_file_path):
            raise InvalidFileException(original_file_path, 'Failed to find script path: ' + original_file_path)

        with open(original_file_path, 'r') as f:
            original_config_json = json.load(f)
            short_original_config = self.read_short_config(original_config_json, original_file_path)

        name = config['name']

        search_result = self._find_config(name, user)
        if (search_result is not None) and (os.path.basename(search_result.path) != filename):
            raise InvalidConfigException('Another script found with the same name: ' + name)

        if not self._can_edit_script(user, short_original_config):
            raise ConfigNotAllowedException(str(user) + ' is not allowed to modify ' + short_original_config.name)

        self._preprocess_script_fields(config, original_config_json, uploaded_script, user)

        LOGGER.info('Updating script config "' + name + '" in ' + original_file_path)
        self._save_config(config, original_file_path)

    def read_short_config(self, config_json, file_path):
        return script_config.read_short(
            file_path,
            config_json,
            self._group_scripts_by_folder,
            self._script_configs_folder)

    def delete_config(self, user, name):
        self._check_admin_access(user)

        search_result = self._find_config(name, user)
        if search_result is None:
            raise InvalidConfigException(f'Config with the name "{name}" not found')

        (short_config, path, config_object) = search_result

        if not self._can_edit_script(user, short_config):
            raise ConfigNotAllowedException(
                f'{str(user)} has no admin access to {short_config.name}'
            )

        archive_file_name = _create_archive_filename(os.path.basename(path))
        archive_file_path = os.path.join(self._scripts_deleted_folder, archive_file_name)
        unique_archive_file_path = file_utils.create_unique_filename(archive_file_path, 100)

        LOGGER.info(
            f'Archiving script config "{name}" from {path} to {unique_archive_file_path}'
        )
        shutil.move(path, unique_archive_file_path)

    def load_script_code(self, script_name, user):
        if not self._authorizer.can_edit_code(user.user_id):
            logging.warning('User ' + str(user) + ' is not allowed to edit code')
            raise InvalidAccessException('Code edit is not allowed for this user')

        config_wrapper = self.load_config(script_name, user)
        if config_wrapper is None:
            return None

        config = config_wrapper.get('config')
        return self._load_script_code_by_config(config)

    def _load_script_code_by_config(self, plain_config):
        script_path = plain_config.get(SCRIPT_PATH_FIELD)
        if is_blank(script_path):
            raise InvalidFileException('', 'Script path is not specified')

        command = process_utils.split_command(script_path, plain_config.get(WORKING_DIR_FIELD))
        binary_files = []
        for argument in command:
            if file_utils.exists(argument):
                if file_utils.is_binary(argument):
                    binary_files.append(argument)
                    continue

                return {'code': file_utils.read_file(argument), 'file_path': argument}

        if binary_files:
            if len(binary_files) == 1:
                return {'code': None, 'file_path': binary_files[0], 'code_edit_error': 'Cannot edit binary file'}

            raise InvalidFileException('command', 'Cannot choose which binary file to edit: ' + str(binary_files))

        if len(command) == 1:
            return {'code': None, 'file_path': command[0], 'code_edit_error': 'Script path does not exist'}

        raise InvalidFileException('command', 'Failed to find script path in command "' + script_path + '"')

    def _save_config(self, config, path):
        sorted_config = get_sorted_config(config)
        config_json = json.dumps(sorted_config, indent=2)
        file_utils.write_file(path, config_json)

    def load_config_file(self, path, content):
        if path.endswith('.yaml'):
            config_object = custom_yaml.loads(content)
        else:
            config_object = custom_json.loads(content)

        return config_object

    def list_configs(self, user, mode=None):
        edit_mode = mode == 'edit'
        if edit_mode:
            self._check_admin_access(user)

        conf_service = self

        has_admin_rights = self._authorizer.is_admin(user.user_id)

        def load_script(path, content) -> Optional[ShortConfig]:
            try:
                config_object = self.load_config_file(path, content)
                short_config = self.read_short_config(config_object, path)

                if short_config is None:
                    return None

                if edit_mode and (not conf_service._can_edit_script(user, short_config)):
                    return None

                if (not edit_mode) and (not conf_service._can_access_script(user, short_config)):
                    return None

                return short_config
            except json.decoder.JSONDecodeError:
                LOGGER.exception(CorruptConfigFileException.VERBOSE_ERROR + ': ' + path)
                return create_failed_short_config(path, has_admin_rights)
            except Exception:
                LOGGER.exception('Could not load script: ' + path)

        return self._visit_script_configs(load_script)

    def load_config_model(self, name, user, parameter_values=None, skip_invalid_parameters=False):
        search_result = self._find_config(name, user)

        if search_result is None:
            return None

        (short_config, path, config_object) = search_result

        if not self._can_access_script(user, short_config):
            raise ConfigNotAllowedException()

        return self._load_script_config(
            path,
            config_object,
            user,
            parameter_values,
            skip_invalid_parameters,
            self._process_invoker,
            self._group_scripts_by_folder,
            self._script_configs_folder)

    def _visit_script_configs(self, visitor):
        configs_dir = self._script_configs_folder

        files = []
        # Read config file from within directories too
        for _root, _dirs, _files in os.walk(configs_dir, topdown=True):
            for name in _files:
                files.append(os.path.join(_root, name))

        configs = [file for file in files if file.lower().endswith(".json") or file.lower().endswith(".yaml")]
        configs.sort()

        result = []

        for config_path in configs:
            try:
                content = file_utils.read_file(config_path)

                visit_result = visitor(config_path, content)
                if visit_result is not None:
                    result.append(visit_result)

            except StopIteration as e:
                if e.value is not None:
                    result.append(e.value)

            except:
                LOGGER.exception("Couldn't read the file: " + config_path)

        return result

    def _find_config(self, name, user) -> Optional[ConfigSearchResult]:
        has_admin_rights = self._authorizer.is_admin(user.user_id)

        def find_and_load(path: str, content):
            try:
                config_object = self.load_config_file(path, content)
                short_config = self.read_short_config(config_object, path)

                if short_config is None:
                    return None
            except json.decoder.JSONDecodeError:
                short_config = create_failed_short_config(path, has_admin_rights)
                config_object = None

            except Exception:
                LOGGER.exception('Could not load script config: ' + path)
                return None

            if short_config.name != name.strip():
                return None

            raise StopIteration(ConfigSearchResult(short_config, path, config_object))

        configs = self._visit_script_configs(find_and_load)
        if not configs:
            return None

        found_config = configs[0]

        if found_config.short_config.parsing_failed:
            raise CorruptConfigFileException()

        return found_config

    @staticmethod
    def _load_script_config(
            path,
            content_or_json_dict,
            user,
            parameter_values,
            skip_invalid_parameters,
            process_invoker,
            group_scripts_by_folder,
            script_configs_folder):

        if isinstance(content_or_json_dict, str):
            json_object = custom_json.loads(content_or_json_dict)
        else:
            json_object = content_or_json_dict
        config = script_config.ConfigModel(
            json_object,
            path,
            user.get_username(),
            user.get_audit_name(),
            group_scripts_by_folder,
            script_configs_folder,
            process_invoker,
            pty_enabled_default=os_utils.is_pty_supported())

        if parameter_values is not None:
            config.set_all_param_values(parameter_values, skip_invalid_parameters)

        return config

    def _can_access_script(self, user, short_config):
        return self._authorizer.is_allowed(user.user_id, short_config.allowed_users)

    def _can_edit_script(self, user, short_config):
        return self._authorizer.is_allowed(user.user_id, short_config.admin_users)

    def _check_admin_access(self, user):
        if not self._authorizer.is_admin(user.user_id):
            raise AdminAccessRequiredException('Admin access to scripts is prohibited for ' + str(user))

    def _preprocess_script_fields(self, config, original_config_json, uploaded_script, user):
        script_config = config.get('script')
        if not script_config:
            raise InvalidConfigException('script option is required')

        if SCRIPT_PATH_FIELD in config:
            del config[SCRIPT_PATH_FIELD]
        del config['script']

        new_path = strip(script_config.get('path'))
        if is_blank(new_path):
            raise InvalidConfigException('script.path option is required')

        config[SCRIPT_PATH_FIELD] = new_path

        mode = script_config.get('mode')
        if is_blank(mode) or mode == SCRIPT_EDIT_PATH_MODE:
            pass

        elif mode in (SCRIPT_EDIT_UPLOAD_MODE, SCRIPT_EDIT_CODE_MODE):
            if not self._authorizer.can_edit_code(user.user_id):
                raise InvalidAccessException('User ' + str(user) + ' is not allowed to edit code')

            if mode == SCRIPT_EDIT_UPLOAD_MODE:
                if uploaded_script is None:
                    raise InvalidConfigException('Uploaded script should be specified')

            if original_config_json is None:  # new config
                if mode == SCRIPT_EDIT_UPLOAD_MODE:
                    # escaped name is needed, when uploaded file and server has different OSes,
                    # thus different special characters
                    escaped_name = to_filename(uploaded_script.filename)
                    target_path = os.path.join(self._scripts_folder, escaped_name)
                else:
                    filename = os.path.basename(new_path)
                    target_path = os.path.join(self._scripts_folder, _escape_characters_in_filename(filename))

                script_path = file_utils.create_unique_filename(target_path, 100)
                config[SCRIPT_PATH_FIELD] = script_path

            else:
                existing_code = self._load_script_code_by_config(original_config_json)
                script_path = existing_code['file_path']

                if (mode == SCRIPT_EDIT_CODE_MODE) and existing_code.get('code_edit_error') is not None:
                    raise InvalidConfigException('Failed to edit code: ' + existing_code.get('code_edit_error'))

                if new_path != original_config_json.get(SCRIPT_PATH_FIELD):
                    raise InvalidConfigException('script.path override is not allowed for ' + mode + ' mode')

            if mode == SCRIPT_EDIT_UPLOAD_MODE:
                file_utils.write_file(script_path, uploaded_script.body, byte_content=True)
            else:
                code = script_config.get('code')
                if code is None:
                    raise InvalidConfigException('script.code should be specified')

                # Fix non-native OS line endings.
                # From python docs:
                #   Do not use os.linesep as a line terminator when writing files opened in text mode (the default);
                #   use a single '\n' instead, on all platforms.
                normalized_code = code.replace('\r\n', '\n').replace('\r', '\n')
                file_utils.write_file(script_path, normalized_code)

            file_utils.make_executable(script_path)

        else:
            raise InvalidConfigException('Unsupported mode: ' + mode)


class ConfigNotAllowedException(Exception):
    def __init__(self, message=None):
        super().__init__(message)


class AdminAccessRequiredException(Exception):
    def __init__(self, message):
        super().__init__(message)


class InvalidAccessException(Exception):
    def __init__(self, message=None):
        super().__init__(message)


class CorruptConfigFileException(Exception):
    HTTP_CODE = 422
    VERBOSE_ERROR = 'Cannot parse script config file'

    def __init__(self, message=VERBOSE_ERROR):
        super().__init__(message)
