#   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 annotations

import argparse
from collections.abc import Iterable, Sequence
import itertools
import json
import logging
from typing import Any

from osc_lib import utils as oscutils

from ironicclient.common.i18n import _
from ironicclient.common import utils
from ironicclient import exc
from ironicclient.osc import command
from ironicclient.v1 import resource_fields as res_fields


_RUNBOOK_STEPS_HELP = _(
    "The runbook steps. May be the path to a YAML file containing the "
    "runbook steps; OR '-', with the runbook steps being read from standard "
    "input; OR a JSON string. The value should be a list of runbook step "
    "dictionaries; each dictionary should have keys 'interface', 'step', "
    "'args' and 'order'.")


class CreateBaremetalRunbook(command.ShowOne):
    """Create a new runbook"""

    log: logging.Logger = logging.getLogger(
        __name__ + ".CreateBaremetalRunbook")

    def get_parser(self, prog_name: str) -> argparse.ArgumentParser:
        parser: argparse.ArgumentParser
        parser = super().get_parser(prog_name)

        parser.add_argument(
            '--name',
            metavar='<name>',
            required=True,
            help=_('Unique name for this runbook.')
        )
        parser.add_argument(
            '--description',
            metavar='<description>',
            help=_('Description of the runbook.')
        )
        parser.add_argument(
            '--uuid',
            dest='uuid',
            metavar='<uuid>',
            help=_('UUID of the runbook.'))
        parser.add_argument(
            '--public',
            dest='public',
            nargs='?',
            const='true',
            metavar='<public>',
            help=_('Whether the runbook will be private or public.')
        )
        parser.add_argument(
            '--owner',
            metavar='<owner>',
            help=_('Owner of the runbook.')
        )
        parser.add_argument(
            '--extra',
            metavar="<key=value>",
            action='append',
            help=_("Record arbitrary key/value metadata. "
                   "Can be specified multiple times."))
        parser.add_argument(
            '--steps',
            metavar="<steps>",
            required=True,
            help=_RUNBOOK_STEPS_HELP
        )
        parser.add_argument(
            '--disable-ramdisk',
            action='store_true',
            default=None,
            help=_("ironic-python-agent will not be booted when this "
                   "runbook is used. Only steps explicitly marked as not "
                   "requiring ironic-python-agent can be executed with "
                   "this set.")
        )
        return parser

    def take_action(
        self, parsed_args: argparse.Namespace,
    ) -> tuple[tuple[str, ...], tuple[Any, ...]]:
        self.log.debug("take_action(%s)", parsed_args)
        manager = self.app.client_manager
        baremetal_client = manager.baremetal

        steps = utils.handle_json_arg(parsed_args.steps, 'runbook steps')

        field_list: list[str] = [
            'name', 'uuid', 'owner', 'public', 'extra', 'disable_ramdisk',
        ]

        # Check if API version supports the description field. Note that
        # traits cannot be set at creation time: the API only accepts them
        # via the /runbooks/<ident>/traits sub-resource (see the
        # AddTraitBaremetalRunbook/RemoveTraitBaremetalRunbook commands).
        if utils.check_api_version_support(
                baremetal_client.current_api_version, "1.112"):
            field_list.append('description')
        elif parsed_args.description is not None:
            raise exc.UnsupportedVersion(
                _("--description requires API version 1.112 or later"))

        fields = dict((k, v) for (k, v) in vars(parsed_args).items()
                      if k in field_list and v is not None)
        if parsed_args.public is not None:
            fields['public'] = utils.bool_argument_value(
                '--public', parsed_args.public, strict=True)
        fields = utils.args_array_to_dict(fields, 'extra')
        runbook = baremetal_client.runbook.create(steps=steps,
                                                  **fields)

        data = dict([(f, getattr(runbook, f, '')) for f in
                     res_fields.RUNBOOK_DETAILED_RESOURCE.fields])

        return self.dict2columns(data)


class ShowBaremetalRunbook(command.ShowOne):
    """Show baremetal runbook details."""

    log: logging.Logger = logging.getLogger(
        __name__ + ".ShowBaremetalRunbook")

    def get_parser(self, prog_name: str) -> argparse.ArgumentParser:
        parser: argparse.ArgumentParser
        parser = super().get_parser(prog_name)
        parser.add_argument(
            "runbook",
            metavar="<runbook>",
            help=_("Name or UUID of the runbook.")
        )
        parser.add_argument(
            '--fields',
            nargs='+',
            dest='fields',
            metavar='<field>',
            action='append',
            choices=res_fields.RUNBOOK_DETAILED_RESOURCE.fields,
            default=[],
            help=_("One or more runbook fields. Only these fields "
                   "will be fetched from the server.")
        )
        return parser

    def take_action(
        self, parsed_args: argparse.Namespace,
    ) -> tuple[tuple[str, ...], tuple[Any, ...]]:
        self.log.debug("take_action(%s)", parsed_args)

        manager = self.app.client_manager
        baremetal_client = manager.baremetal
        fields: list[str] | None
        fields = (
            list(itertools.chain.from_iterable(parsed_args.fields))
            or None)

        runbook = baremetal_client.runbook.get(
            parsed_args.runbook, fields=fields)._info

        runbook.pop("links", None)
        return self.dict2columns(dict(sorted(runbook.items())))


class SetBaremetalRunbook(command.Command):
    """Set baremetal runbook properties."""

    log: logging.Logger = logging.getLogger(
        __name__ + ".SetBaremetalRunbook")

    def get_parser(self, prog_name: str) -> argparse.ArgumentParser:
        parser: argparse.ArgumentParser
        parser = super().get_parser(prog_name)

        parser.add_argument(
            'runbook',
            metavar='<runbook>',
            help=_("Name or UUID of the runbook")
        )
        parser.add_argument(
            '--name',
            metavar='<name>',
            help=_('Set unique name of the runbook.')
        )
        parser.add_argument(
            '--description',
            metavar='<description>',
            help=_('Set description of the runbook.')
        )
        parser.add_argument(
            '--public',
            dest='public',
            nargs='?',
            const='true',
            metavar='<public>',
            help=_('Make a private runbook public.')
        )
        parser.add_argument(
            '--owner',
            metavar='<owner>',
            help=_('Set owner of a runbook.')
        )
        parser.add_argument(
            '--steps',
            metavar="<steps>",
            help=_RUNBOOK_STEPS_HELP
        )
        parser.add_argument(
            "--extra",
            metavar="<key=value>",
            action='append',
            help=_('Extra to set on this baremetal runbook '
                   '(repeat option to set multiple extras).'),
        )
        parser.add_argument(
            '--disable-ramdisk',
            dest='disable_ramdisk',
            nargs='?',
            const='true',
            metavar='<disable_ramdisk>',
            help=_('Whether ironic-python-agent should not be booted when '
                   'this runbook is used.')
        )
        return parser

    def take_action(self, parsed_args: argparse.Namespace) -> None:
        self.log.debug("take_action(%s)", parsed_args)

        manager = self.app.client_manager
        baremetal_client = manager.baremetal

        properties: list[dict[str, Any]] = []
        # Check if API version supports description field
        supports_new_fields = utils.check_api_version_support(
            baremetal_client.current_api_version, "1.112")

        if parsed_args.name:
            name = ["name=%s" % parsed_args.name]
            properties.extend(utils.args_array_to_patch('add', name))
        if parsed_args.description:
            if not supports_new_fields:
                raise exc.UnsupportedVersion(
                    _("--description requires API version 1.112 or later"))
            description = ["description=%s" % parsed_args.description]
            properties.extend(utils.args_array_to_patch('add', description))
        if parsed_args.owner:
            owner = ["owner=%s" % parsed_args.owner]
            properties.extend(utils.args_array_to_patch('add', owner))
        if parsed_args.public is not None:
            to_bool = utils.bool_argument_value(
                '--public', parsed_args.public, strict=True)
            public = ["public=%s" % to_bool]
            properties.extend(utils.args_array_to_patch('add', public))
        if parsed_args.steps:
            steps = utils.handle_json_arg(parsed_args.steps, 'runbook steps')
            steps = ["steps=%s" % json.dumps(steps)]
            properties.extend(utils.args_array_to_patch('add', steps))
        if parsed_args.extra:
            properties.extend(utils.args_array_to_patch(
                'add', ['extra/' + x for x in parsed_args.extra]))
        if parsed_args.disable_ramdisk is not None:
            to_bool = utils.bool_argument_value(
                '--disable-ramdisk', parsed_args.disable_ramdisk,
                strict=True)
            disable_ramdisk = [
                "disable_ramdisk=%s" % json.dumps(to_bool)]
            properties.extend(
                utils.args_array_to_patch('add', disable_ramdisk))

        if properties:
            baremetal_client.runbook.update(parsed_args.runbook,
                                            properties)
        else:
            self.log.warning("Please specify what to set.")


class UnsetBaremetalRunbook(command.Command):
    """Unset baremetal runbook properties."""
    log: logging.Logger = logging.getLogger(
        __name__ + ".UnsetBaremetalRunbook")

    def get_parser(self, prog_name: str) -> argparse.ArgumentParser:
        parser: argparse.ArgumentParser
        parser = super().get_parser(prog_name)

        parser.add_argument(
            'runbook',
            metavar='<runbook>',
            help=_("Name or UUID of the runbook")
        )
        parser.add_argument(
            '--name',
            action='store_true',
            help=_('Unset the name of the runbook.')
        )
        parser.add_argument(
            '--description',
            action='store_true',
            help=_('Unset the description of the runbook.')
        )
        parser.add_argument(
            '--public',
            dest='public',
            action='store_true',
            help=_('Make a public runbook private.')
        )
        parser.add_argument(
            '--owner',
            dest='owner',
            action='store_true',
            help=_('Unset owner of a runbook.')
        )
        parser.add_argument(
            "--extra",
            metavar="<key>",
            action='append',
            help=_('Extra to unset on this baremetal runbook '
                   '(repeat option to unset multiple extras).'),
        )
        parser.add_argument(
            '--disable-ramdisk',
            dest='disable_ramdisk',
            action='store_true',
            help=_('Unset disable_ramdisk of a runbook.')
        )

        return parser

    def take_action(self, parsed_args: argparse.Namespace) -> None:
        self.log.debug("take_action(%s)", parsed_args)

        manager = self.app.client_manager
        baremetal_client = manager.baremetal

        properties: list[dict[str, Any]] = []
        # Check if API version supports description field
        supports_new_fields = utils.check_api_version_support(
            baremetal_client.current_api_version, "1.112")

        for field in ['name', 'owner', 'public', 'disable_ramdisk']:
            if getattr(parsed_args, field):
                properties.extend(utils.args_array_to_patch('remove', [field]))

        # Handle description field separately with version check
        if getattr(parsed_args, 'description', False):
            if not supports_new_fields:
                raise exc.UnsupportedVersion(
                    _("--description requires API version 1.112 or later"))
            properties.extend(
                utils.args_array_to_patch('remove', ['description']))

        if parsed_args.extra:
            properties.extend(utils.args_array_to_patch('remove',
                              ['extra/' + x for x in parsed_args.extra]))

        if properties:
            baremetal_client.runbook.update(parsed_args.runbook,
                                            properties)
        else:
            self.log.warning("Please specify what to unset.")


class DeleteBaremetalRunbook(command.Command):
    """Delete runbook(s)."""

    log: logging.Logger = logging.getLogger(
        __name__ + ".DeleteBaremetalRunbook")

    def get_parser(self, prog_name: str) -> argparse.ArgumentParser:
        parser: argparse.ArgumentParser
        parser = super().get_parser(prog_name)
        parser.add_argument(
            "runbooks",
            metavar="<runbook>",
            nargs="+",
            help=_("Name(s) or UUID(s) of the runbook(s) to delete.")
        )

        return parser

    def take_action(self, parsed_args: argparse.Namespace) -> None:
        self.log.debug("take_action(%s)", parsed_args)

        manager = self.app.client_manager
        baremetal_client = manager.baremetal

        failures: list[str] = []
        for runbook in parsed_args.runbooks:
            try:
                baremetal_client.runbook.delete(runbook)
                print(_('Deleted runbook %s') % runbook)
            except exc.ClientException as e:
                failures.append(_("Failed to delete runbook "
                                  "%(runbook)s: %(error)s")
                                % {'runbook': runbook, 'error': e})

        if failures:
            raise exc.ClientException("\n".join(failures))


class ListBaremetalRunbook(command.Lister):
    """List baremetal runbooks."""

    log: logging.Logger = logging.getLogger(
        __name__ + ".ListBaremetalRunbook")

    def get_parser(self, prog_name: str) -> argparse.ArgumentParser:
        parser: argparse.ArgumentParser
        parser = super().get_parser(prog_name)
        parser.add_argument(
            '--limit',
            metavar='<limit>',
            type=int,
            help=_('Maximum number of runbooks to return per request, '
                   '0 for no limit. Default is the maximum number used '
                   'by the Baremetal API Service.')
        )
        parser.add_argument(
            '--marker',
            metavar='<runbook>',
            help=_('Runbook UUID (for example, of the last runbook '
                   'in the list from a previous request). Returns '
                   'the list of runbooks after this UUID.')
        )
        parser.add_argument(
            '--sort',
            metavar="<key>[:<direction>]",
            help=_('Sort output by specified runbook fields and '
                   'directions (asc or desc) (default: asc). Multiple fields '
                   'and directions can be specified, separated by comma.')
        )
        display_group = parser.add_mutually_exclusive_group()
        display_group.add_argument(
            '--long',
            dest='detail',
            action='store_true',
            default=False,
            help=_("Show detailed information about runbooks.")
        )
        display_group.add_argument(
            '--fields',
            nargs='+',
            dest='fields',
            metavar='<field>',
            action='append',
            default=[],
            choices=res_fields.RUNBOOK_DETAILED_RESOURCE.fields,
            help=_("One or more runbook fields. Only these fields "
                   "will be fetched from the server. Can not be used when "
                   "'--long' is specified.")
        )
        return parser

    def take_action(
        self, parsed_args: argparse.Namespace,
    ) -> tuple[Sequence[str], Iterable[Any]]:
        self.log.debug("take_action(%s)", parsed_args)
        manager = self.app.client_manager
        client = manager.baremetal

        columns = res_fields.RUNBOOK_RESOURCE.fields

        params: dict[str, object] = {}
        if parsed_args.limit is not None and parsed_args.limit < 0:
            raise exc.CommandError(
                _('Expected non-negative --limit, got %s') %
                parsed_args.limit)
        params['limit'] = parsed_args.limit
        params['marker'] = parsed_args.marker

        if parsed_args.detail:
            params['detail'] = parsed_args.detail
            columns = res_fields.RUNBOOK_DETAILED_RESOURCE.fields

        elif parsed_args.fields:
            params['detail'] = False
            fields = itertools.chain.from_iterable(parsed_args.fields)
            resource = res_fields.Resource(list(fields))
            columns = resource.fields
            params['fields'] = columns

        self.log.debug("params(%s)", params)
        data = client.runbook.list(**params)

        data = oscutils.sort_items(data, parsed_args.sort)

        return (columns,
                (oscutils.get_item_properties(s, columns) for s in data))


class ListTraitsBaremetalRunbook(command.Lister):
    """List a runbook's traits."""

    log = logging.getLogger(__name__ + ".ListTraitsBaremetalRunbook")

    def get_parser(self, prog_name: str) -> argparse.ArgumentParser:
        parser = super(ListTraitsBaremetalRunbook, self).get_parser(prog_name)

        parser.add_argument(
            'runbook',
            metavar='<runbook>',
            help=_("Name or UUID of the runbook"))

        return parser

    def take_action(
        self, parsed_args: argparse.Namespace
    ) -> tuple[tuple[str, ...], list[list[str]]]:
        self.log.debug("take_action(%s)", parsed_args)

        columns = res_fields.TRAIT_RESOURCE.fields

        baremetal_client = self.app.client_manager.baremetal
        traits = baremetal_client.runbook.get_traits(parsed_args.runbook)

        return (columns, [[trait] for trait in traits])


class AddTraitBaremetalRunbook(command.Command):
    """Add traits to a runbook."""

    log = logging.getLogger(__name__ + ".AddTraitBaremetalRunbook")

    def get_parser(self, prog_name: str) -> argparse.ArgumentParser:
        parser = super(AddTraitBaremetalRunbook, self).get_parser(prog_name)

        parser.add_argument(
            'runbook',
            metavar='<runbook>',
            help=_("Name or UUID of the runbook"))
        parser.add_argument(
            'traits',
            nargs='+',
            metavar='<trait>',
            help=_("Trait(s) to add"))

        return parser

    def take_action(self, parsed_args: argparse.Namespace) -> None:
        self.log.debug("take_action(%s)", parsed_args)

        baremetal_client = self.app.client_manager.baremetal

        failures = []
        for trait in parsed_args.traits:
            try:
                baremetal_client.runbook.add_trait(parsed_args.runbook, trait)
                print(_('Added trait %s') % trait)
            except exc.ClientException as e:
                failures.append(_("Failed to add trait %(trait)s: %(error)s")
                                % {'trait': trait, 'error': e})

        if failures:
            raise exc.ClientException("\n".join(failures))


class RemoveTraitBaremetalRunbook(command.Command):
    """Remove trait(s) from a runbook."""

    log = logging.getLogger(__name__ + ".RemoveTraitBaremetalRunbook")

    def get_parser(self, prog_name: str) -> argparse.ArgumentParser:
        parser = super(RemoveTraitBaremetalRunbook, self).get_parser(prog_name)

        parser.add_argument(
            'runbook',
            metavar='<runbook>',
            help=_("Name or UUID of the runbook"))
        all_or_trait = parser.add_mutually_exclusive_group(required=True)
        all_or_trait.add_argument(
            '--all',
            dest='remove_all',
            action='store_true',
            help=_("Remove all traits"))
        all_or_trait.add_argument(
            'traits',
            metavar='<trait>',
            nargs='*',
            default=[],
            help=_("Trait(s) to remove"))

        return parser

    def take_action(self, parsed_args: argparse.Namespace) -> None:
        self.log.debug("take_action(%s)", parsed_args)

        baremetal_client = self.app.client_manager.baremetal

        failures = []
        if parsed_args.remove_all:
            baremetal_client.runbook.remove_all_traits(parsed_args.runbook)
            print(_('Removed all traits from runbook %s')
                  % parsed_args.runbook)
        else:
            for trait in parsed_args.traits:
                try:
                    baremetal_client.runbook.remove_trait(
                        parsed_args.runbook, trait)
                    print(_('Removed trait %s') % trait)
                except exc.ClientException as e:
                    failures.append(_("Failed to remove trait %(trait)s: "
                                      "%(error)s")
                                    % {'trait': trait, 'error': e})

        if failures:
            raise exc.ClientException("\n".join(failures))
