# Copyright 2020 The StackStorm Authors.
# Copyright 2019 Extreme Networks, 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.

from __future__ import absolute_import

from typing import List
from typing import Any
from typing import Dict

import os
import abc
import six
import json
import logging

from functools import wraps

import yaml
from six.moves import http_client

from st2client import commands
from st2client.exceptions.operations import OperationFailureException
from st2client.formatters import table
from st2client.utils.types import OrderedSet

ALLOWED_EXTS = [".json", ".yaml", ".yml"]
PARSER_FUNCS = {".json": json.load, ".yml": yaml.safe_load, ".yaml": yaml.safe_load}
LOG = logging.getLogger(__name__)


def add_auth_token_to_kwargs_from_cli(func):
    @wraps(func)
    def decorate(*args, **kwargs):
        ns = args[1]
        if getattr(ns, "token", None):
            kwargs["token"] = ns.token
        if getattr(ns, "api_key", None):
            kwargs["api_key"] = ns.api_key
        return func(*args, **kwargs)

    return decorate


class ResourceCommandError(Exception):
    pass


class ResourceNotFoundError(Exception):
    pass


class ResourceBranch(commands.Branch):
    def __init__(
        self,
        resource,
        description,
        app,
        subparsers,
        parent_parser=None,
        read_only=False,
        commands=None,
        has_disable=False,
    ):

        self.resource = resource
        super(ResourceBranch, self).__init__(
            self.resource.get_alias().lower(),
            description,
            app,
            subparsers,
            parent_parser=parent_parser,
        )

        # Registers subcommands for managing the resource type.
        self.subparsers = self.parser.add_subparsers(
            help=(
                "List of commands for managing %s."
                % self.resource.get_plural_display_name().lower()
            )
        )

        # Resolves if commands need to be overridden.
        commands = commands or {}
        cmd_map = {
            "list": ResourceListCommand,
            "get": ResourceGetCommand,
            "create": ResourceCreateCommand,
            "update": ResourceUpdateCommand,
            "delete": ResourceDeleteCommand,
            "enable": ResourceEnableCommand,
            "disable": ResourceDisableCommand,
            "clone": ResourceCloneCommand,
        }
        for cmd, cmd_class in cmd_map.items():
            if cmd not in commands:
                commands[cmd] = cmd_class

        # Instantiate commands.
        args = [self.resource, self.app, self.subparsers]
        self.commands["list"] = commands["list"](*args)
        self.commands["get"] = commands["get"](*args)

        if not read_only:
            self.commands["create"] = commands["create"](*args)
            self.commands["update"] = commands["update"](*args)
            self.commands["delete"] = commands["delete"](*args)
            self.commands["clone"] = commands["clone"](*args)

        if has_disable:
            self.commands["enable"] = commands["enable"](*args)
            self.commands["disable"] = commands["disable"](*args)


@six.add_metaclass(abc.ABCMeta)
class ResourceCommand(commands.Command):
    pk_argument_name = None

    def __init__(self, resource, *args, **kwargs):

        has_token_opt = kwargs.pop("has_token_opt", True)

        super(ResourceCommand, self).__init__(*args, **kwargs)

        self.resource = resource

        if has_token_opt:
            self.parser.add_argument(
                "-t",
                "--token",
                dest="token",
                help="Access token for user authentication. "
                "Get ST2_AUTH_TOKEN from the environment "
                "variables by default.",
            )
            self.parser.add_argument(
                "--api-key",
                dest="api_key",
                help="Api Key for user authentication. "
                "Get ST2_API_KEY from the environment "
                "variables by default.",
            )

        # Formatter flags
        self.parser.add_argument(
            "-j",
            "--json",
            action="store_true",
            dest="json",
            help="Print output in JSON format.",
        )
        self.parser.add_argument(
            "-y",
            "--yaml",
            action="store_true",
            dest="yaml",
            help="Print output in YAML format.",
        )

    @property
    def manager(self):
        return self.app.client.managers[self.resource.__name__]

    @property
    def arg_name_for_resource_id(self):
        resource_name = self.resource.get_display_name().lower()
        return "%s-id" % resource_name.replace(" ", "-")

    def print_not_found(self, name):
        print('%s "%s" is not found.\n' % (self.resource.get_display_name(), name))

    def get_resource(self, name_or_id, **kwargs):
        pk_argument_name = self.pk_argument_name

        if pk_argument_name == "name_or_id":
            instance = self.get_resource_by_name_or_id(name_or_id=name_or_id, **kwargs)
        elif pk_argument_name == "ref_or_id":
            instance = self.get_resource_by_ref_or_id(ref_or_id=name_or_id, **kwargs)
        else:
            instance = self.get_resource_by_pk(pk=name_or_id, **kwargs)

        return instance

    def get_resource_by_pk(self, pk, **kwargs):
        """
        Retrieve resource by a primary key.
        """
        try:
            instance = self.manager.get_by_id(pk, **kwargs)
        except Exception as e:
            # Hack for "Unauthorized" exceptions, we do want to propagate those
            response = getattr(e, "response", None)
            status_code = getattr(response, "status_code", None)
            if status_code and status_code == http_client.FORBIDDEN:
                raise e
            if status_code and status_code == http_client.UNAUTHORIZED:
                raise e

            instance = None

        return instance

    def get_resource_by_id(self, id, **kwargs):
        instance = self.get_resource_by_pk(pk=id, **kwargs)

        if not instance:
            message = 'Resource with id "%s" doesn\'t exist.' % (id)
            raise ResourceNotFoundError(message)
        return instance

    def get_resource_by_name(self, name, **kwargs):
        """
        Retrieve resource by name.
        """
        instance = self.manager.get_by_name(name, **kwargs)
        return instance

    def get_resource_by_name_or_id(self, name_or_id, **kwargs):
        instance = self.get_resource_by_name(name=name_or_id, **kwargs)
        if not instance:
            instance = self.get_resource_by_pk(pk=name_or_id, **kwargs)

        if not instance:
            message = 'Resource with id or name "%s" doesn\'t exist.' % (name_or_id)
            raise ResourceNotFoundError(message)
        return instance

    def get_resource_by_ref_or_id(self, ref_or_id, **kwargs):
        instance = self.manager.get_by_ref_or_id(ref_or_id=ref_or_id, **kwargs)

        if not instance:
            message = 'Resource with id or reference "%s" doesn\'t exist.' % (ref_or_id)
            raise ResourceNotFoundError(message)
        return instance

    def _get_multiple_resources(
        self, resource_ids: List[str], kwargs: Dict[str, Any]
    ) -> List[Any]:
        """
        Return multiple resource instances for the provided resource ids.
        If a resource is not found, an error is printed. This method only throws when operating on
        a single resource.
        :param resource_ids: A list of resources to retrieve instances for.
        :param kwargs: Dictionary with keyword arguments which are passed to get_resource_by_id.
        """
        more_than_one_resource = len(resource_ids) > 1

        resources = []
        for resource_id in resource_ids:
            try:
                resource = self.get_resource_by_id(resource_id, **kwargs)
            except ResourceNotFoundError:
                self.print_not_found(resource_id)

                if not more_than_one_resource:
                    # For backward compatibility reasons and to comply with common "get one"
                    # behavior, we only fail if a single source is requested
                    raise ResourceNotFoundError("Resource %s not found." % resource_id)

                continue

            resources.append(resource)
        return resources

    @abc.abstractmethod
    def run(self, args, **kwargs):
        raise NotImplementedError

    @abc.abstractmethod
    def run_and_print(self, args, **kwargs):
        raise NotImplementedError

    def _get_metavar_for_argument(self, argument):
        return argument.replace("_", "-")

    def _get_help_for_argument(self, resource, argument):
        argument_display_name = argument.title()
        resource_display_name = resource.get_display_name().lower()

        if "ref" in argument:
            result = "Reference or ID of the %s." % (resource_display_name)
        elif "name_or_id" in argument:
            result = "Name or ID of the %s." % (resource_display_name)
        else:
            result = "%s of the %s." % (argument_display_name, resource_display_name)

        return result


class ResourceViewCommand(ResourceCommand):
    """
    Base class for read / view commands (list and get).
    """

    @classmethod
    def _get_include_attributes(cls, args, extra_attributes=None):
        """
        Return a list of attributes to send to the API using ?include_attributes filter.

        If None / empty list is returned it's assumed no filtering is to be performed and all
        attributes are to be retrieved.

        :param extra_attributes: Additional include attributes which should always be included.
        :type extra_attributes: ``list`` of ``str``
        """
        extra_attributes = extra_attributes or []

        include_attributes = []

        if extra_attributes:
            include_attributes.extend(extra_attributes)

        # If user specifies which attributes to retrieve via CLI --attr / -a argument, take that
        # into account

        # Special case for "all"
        if "all" in args.attr:
            return None

        for attr in args.attr:
            include_attributes.append(attr)

        if include_attributes:
            return include_attributes

        display_attributes = getattr(cls, "display_attributes", [])

        if display_attributes:
            include_attributes += display_attributes

        include_attributes = list(OrderedSet(include_attributes))

        return include_attributes


class ResourceTableCommand(ResourceViewCommand):
    display_attributes = ["id", "name", "description"]

    def __init__(self, resource, name, description, *args, **kwargs):
        super(ResourceTableCommand, self).__init__(
            resource, name, description, *args, **kwargs
        )

        self.parser.add_argument(
            "-a",
            "--attr",
            nargs="+",
            default=self.display_attributes,
            help=(
                "List of attributes to include in the "
                'output. "all" will return all '
                "attributes."
            ),
        )
        self.parser.add_argument(
            "-w",
            "--width",
            nargs="+",
            type=int,
            default=None,
            help=("Set the width of columns in output."),
        )

    @add_auth_token_to_kwargs_from_cli
    def run(self, args, **kwargs):
        include_attributes = self._get_include_attributes(args=args)
        if include_attributes:
            include_attributes = ",".join(include_attributes)
            kwargs["params"] = {"include_attributes": include_attributes}

        return self.manager.get_all(**kwargs)

    def run_and_print(self, args, **kwargs):
        instances = self.run(args, **kwargs)
        self.print_output(
            instances,
            table.MultiColumnTable,
            attributes=args.attr,
            widths=args.width,
            json=args.json,
            yaml=args.yaml,
        )


class ResourceListCommand(ResourceTableCommand):
    def __init__(self, resource, *args, **kwargs):
        super(ResourceListCommand, self).__init__(
            resource,
            "list",
            "Get the list of %s." % resource.get_plural_display_name().lower(),
            *args,
            **kwargs,
        )


class ContentPackResourceListCommand(ResourceListCommand):
    """
    Base command class for use with resources which belong to a content pack.
    """

    def __init__(self, resource, *args, **kwargs):
        super(ContentPackResourceListCommand, self).__init__(resource, *args, **kwargs)

        self.parser.add_argument(
            "-p",
            "--pack",
            type=str,
            help=("Only return resources belonging to the" " provided pack"),
        )

    @add_auth_token_to_kwargs_from_cli
    def run(self, args, **kwargs):
        filters = {"pack": args.pack}
        filters.update(**kwargs)

        include_attributes = self._get_include_attributes(args=args)
        if include_attributes:
            include_attributes = ",".join(include_attributes)
            filters["params"] = {"include_attributes": include_attributes}

        return self.manager.get_all(**filters)


class ResourceGetCommand(ResourceViewCommand):
    display_attributes = ["all"]
    attribute_display_order = ["id", "name", "description"]

    pk_argument_name = "name_or_id"  # name of the attribute which stores resource PK

    help_string = None

    def __init__(self, resource, *args, **kwargs):
        super(ResourceGetCommand, self).__init__(
            resource,
            "get",
            self.help_string
            or "Get individual %s." % resource.get_display_name().lower(),
            *args,
            **kwargs,
        )

        argument = self.pk_argument_name
        metavar = self._get_metavar_for_argument(argument=self.pk_argument_name)
        help = self._get_help_for_argument(
            resource=resource, argument=self.pk_argument_name
        )

        self.parser.add_argument(argument, metavar=metavar, nargs="+", help=help)
        self.parser.add_argument(
            "-a",
            "--attr",
            nargs="+",
            default=self.display_attributes,
            help=(
                "List of attributes to include in the "
                'output. "all" or unspecified will '
                "return all attributes."
            ),
        )

    @add_auth_token_to_kwargs_from_cli
    def run(self, args, **kwargs):
        resource_ids = getattr(args, self.pk_argument_name, None)
        resources = self._get_multiple_resources(
            resource_ids=resource_ids, kwargs=kwargs
        )
        return resources

    def run_and_print(self, args, **kwargs):
        instances = self.run(args, **kwargs)

        for instance in instances:
            self.print_output(
                instance,
                table.PropertyValueTable,
                attributes=args.attr,
                json=args.json,
                yaml=args.yaml,
                attribute_display_order=self.attribute_display_order,
            )


class ContentPackResourceGetCommand(ResourceGetCommand):
    """
    Command for retrieving a single resource which belongs to a content pack.

    Note: All the resources which belong to the content pack can either be
    retrieved by a reference or by an id.
    """

    attribute_display_order = ["id", "pack", "name", "description"]

    pk_argument_name = "ref_or_id"

    def get_resource(self, ref_or_id, **kwargs):
        return self.get_resource_by_ref_or_id(ref_or_id=ref_or_id, **kwargs)


class ResourceCreateCommand(ResourceCommand):
    def __init__(self, resource, *args, **kwargs):
        super(ResourceCreateCommand, self).__init__(
            resource,
            "create",
            "Create a new %s." % resource.get_display_name().lower(),
            *args,
            **kwargs,
        )

        self.parser.add_argument(
            "file",
            help=(
                "JSON/YAML file containing the %s to create."
                % resource.get_display_name().lower()
            ),
        )

    @add_auth_token_to_kwargs_from_cli
    def run(self, args, **kwargs):
        data = load_meta_file(args.file)
        instance = self.resource.deserialize(data)
        return self.manager.create(instance, **kwargs)

    def run_and_print(self, args, **kwargs):
        try:
            instance = self.run(args, **kwargs)
            if not instance:
                raise Exception("Server did not create instance.")
            self.print_output(
                instance,
                table.PropertyValueTable,
                attributes=["all"],
                json=args.json,
                yaml=args.yaml,
            )
        except Exception as e:
            message = six.text_type(e)
            print("ERROR: %s" % (message))
            raise OperationFailureException(message)


class ResourceUpdateCommand(ResourceCommand):
    pk_argument_name = "name_or_id"

    def __init__(self, resource, *args, **kwargs):
        super(ResourceUpdateCommand, self).__init__(
            resource,
            "update",
            "Updating an existing %s." % resource.get_display_name().lower(),
            *args,
            **kwargs,
        )

        argument = self.pk_argument_name
        metavar = self._get_metavar_for_argument(argument=self.pk_argument_name)
        help = self._get_help_for_argument(
            resource=resource, argument=self.pk_argument_name
        )

        self.parser.add_argument(argument, metavar=metavar, help=help)
        self.parser.add_argument(
            "file",
            help=(
                "JSON/YAML file containing the %s to update."
                % resource.get_display_name().lower()
            ),
        )

    @add_auth_token_to_kwargs_from_cli
    def run(self, args, **kwargs):
        resource_id = getattr(args, self.pk_argument_name, None)
        instance = self.get_resource(resource_id, **kwargs)
        data = load_meta_file(args.file)
        modified_instance = self.resource.deserialize(data)

        if not getattr(modified_instance, "id", None):
            modified_instance.id = instance.id
        else:
            if modified_instance.id != instance.id:
                raise Exception(
                    "The value for the %s id in the JSON/YAML file "
                    "does not match the ID provided in the "
                    "command line arguments." % self.resource.get_display_name().lower()
                )
        return self.manager.update(modified_instance, **kwargs)

    def run_and_print(self, args, **kwargs):
        instance = self.run(args, **kwargs)
        try:
            self.print_output(
                instance,
                table.PropertyValueTable,
                attributes=["all"],
                json=args.json,
                yaml=args.yaml,
            )
        except Exception as e:
            print("ERROR: %s" % (six.text_type(e)))
            raise OperationFailureException(six.text_type(e))


class ContentPackResourceUpdateCommand(ResourceUpdateCommand):
    pk_argument_name = "ref_or_id"


class ResourceEnableCommand(ResourceCommand):
    pk_argument_name = "name_or_id"

    def __init__(self, resource, *args, **kwargs):
        super(ResourceEnableCommand, self).__init__(
            resource,
            "enable",
            "Enable an existing %s." % resource.get_display_name().lower(),
            *args,
            **kwargs,
        )

        argument = self.pk_argument_name
        metavar = self._get_metavar_for_argument(argument=self.pk_argument_name)
        help = self._get_help_for_argument(
            resource=resource, argument=self.pk_argument_name
        )

        self.parser.add_argument(argument, metavar=metavar, help=help)

    @add_auth_token_to_kwargs_from_cli
    def run(self, args, **kwargs):
        resource_id = getattr(args, self.pk_argument_name, None)
        instance = self.get_resource(resource_id, **kwargs)

        data = instance.serialize()

        if "ref" in data:
            del data["ref"]

        data["enabled"] = True
        modified_instance = self.resource.deserialize(data)

        return self.manager.update(modified_instance, **kwargs)

    def run_and_print(self, args, **kwargs):
        instance = self.run(args, **kwargs)
        self.print_output(
            instance,
            table.PropertyValueTable,
            attributes=["all"],
            json=args.json,
            yaml=args.yaml,
        )


class ContentPackResourceEnableCommand(ResourceEnableCommand):
    pk_argument_name = "ref_or_id"


class ResourceDisableCommand(ResourceCommand):
    pk_argument_name = "name_or_id"

    def __init__(self, resource, *args, **kwargs):
        super(ResourceDisableCommand, self).__init__(
            resource,
            "disable",
            "Disable an existing %s." % resource.get_display_name().lower(),
            *args,
            **kwargs,
        )

        argument = self.pk_argument_name
        metavar = self._get_metavar_for_argument(argument=self.pk_argument_name)
        help = self._get_help_for_argument(
            resource=resource, argument=self.pk_argument_name
        )

        self.parser.add_argument(argument, metavar=metavar, help=help)

    @add_auth_token_to_kwargs_from_cli
    def run(self, args, **kwargs):
        resource_id = getattr(args, self.pk_argument_name, None)
        instance = self.get_resource(resource_id, **kwargs)

        data = instance.serialize()

        if "ref" in data:
            del data["ref"]

        data["enabled"] = False
        modified_instance = self.resource.deserialize(data)

        return self.manager.update(modified_instance, **kwargs)

    def run_and_print(self, args, **kwargs):
        instance = self.run(args, **kwargs)
        self.print_output(
            instance,
            table.PropertyValueTable,
            attributes=["all"],
            json=args.json,
            yaml=args.yaml,
        )


class ContentPackResourceDisableCommand(ResourceDisableCommand):
    pk_argument_name = "ref_or_id"


class ResourceDeleteCommand(ResourceCommand):
    pk_argument_name = "name_or_id"

    def __init__(self, resource, *args, **kwargs):
        super(ResourceDeleteCommand, self).__init__(
            resource,
            "delete",
            "Delete an existing %s." % resource.get_display_name().lower(),
            *args,
            **kwargs,
        )

        argument = self.pk_argument_name
        metavar = self._get_metavar_for_argument(argument=self.pk_argument_name)
        help = self._get_help_for_argument(
            resource=resource, argument=self.pk_argument_name
        )

        self.parser.add_argument(argument, metavar=metavar, help=help)

    @add_auth_token_to_kwargs_from_cli
    def run(self, args, **kwargs):
        resource_id = getattr(args, self.pk_argument_name, None)
        instance = self.get_resource(resource_id, **kwargs)
        self.manager.delete(instance, **kwargs)

    def run_and_print(self, args, **kwargs):
        resource_id = getattr(args, self.pk_argument_name, None)

        try:
            self.run(args, **kwargs)
            print(
                'Resource with id "%s" has been successfully deleted.' % (resource_id)
            )
        except ResourceNotFoundError:
            self.print_not_found(resource_id)
            raise OperationFailureException("Resource %s not found." % resource_id)


class ContentPackResourceDeleteCommand(ResourceDeleteCommand):
    """
    Base command class for deleting a resource which belongs to a content pack.
    """

    pk_argument_name = "ref_or_id"


class ResourceCloneCommand(ResourceCommand):
    def __init__(self, resource, *args, **kwargs):
        super(ResourceCloneCommand, self).__init__(
            resource,
            "clone",
            "Clone a new %s." % resource.get_display_name().lower(),
            *args,
            **kwargs,
        )

    @add_auth_token_to_kwargs_from_cli
    def run(self, args, **kwargs):
        raise NotImplementedError("clone '%s' is not implemented." % args.parser)

    def run_and_print(self, args, **kwargs):
        self.run(args, **kwargs)


class ContentPackResourceCloneCommand(ResourceCloneCommand):
    pass


def load_meta_file(file_path):
    if not os.path.isfile(file_path):
        raise Exception('File "%s" does not exist.' % file_path)

    file_name, file_ext = os.path.splitext(file_path)
    if file_ext not in ALLOWED_EXTS:
        raise Exception(
            "Unsupported meta type %s, file %s. Allowed: %s"
            % (file_ext, file_path, ALLOWED_EXTS)
        )

    with open(file_path, "r") as f:
        return PARSER_FUNCS[file_ext](f)
