import base64
from pathlib import Path
from typing import Any, Dict, List, Optional, Union

from linode_api4.common import load_and_validate_keys
from linode_api4.errors import UnexpectedResponseError
from linode_api4.groups import Group
from linode_api4.objects import (
    Firewall,
    Instance,
    InstanceDiskEncryptionType,
    Kernel,
    PlacementGroup,
    StackScript,
    Type,
)
from linode_api4.objects.base import _flatten_request_body_recursive
from linode_api4.objects.filtering import Filter
from linode_api4.objects.linode import (
    Backup,
    InstancePlacementGroupAssignment,
    InterfaceGeneration,
    NetworkInterface,
    _expand_placement_group_assignment,
)
from linode_api4.objects.linode_interfaces import LinodeInterfaceOptions
from linode_api4.util import drop_null_keys


class LinodeGroup(Group):
    """
    Encapsulates Linode-related methods of the :any:`LinodeClient`.  This
    should not be instantiated on its own, but should instead be used through
    an instance of :any:`LinodeClient`::

       client = LinodeClient(token)
       instances = client.linode.instances() # use the LinodeGroup

    This group contains all features beneath the `/linode` group in the API v4.
    """

    def types(self, *filters):
        """
        Returns a list of Linode Instance types.  These may be used to create
        or resize Linodes, or simply referenced on their own.  Types can be
        filtered to return specific types, for example::

           standard_types = client.linode.types(Type.class == "standard")

        API documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-types

        :param filters: Any number of filters to apply to this query.
                        See :doc:`Filtering Collections</linode_api4/objects/filtering>`
                        for more details on filtering.

        :returns: A list of types that match the query.
        :rtype: PaginatedList of Type
        """
        return self.client._get_and_filter(Type, *filters)

    def instances(self, *filters):
        """
        Returns a list of Linode Instances on your account.  You may filter
        this query to return only Linodes that match specific criteria::

           prod_linodes = client.linode.instances(Instance.group == "prod")

        API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-instances

        :param filters: Any number of filters to apply to this query.
                        See :doc:`Filtering Collections</linode_api4/objects/filtering>`
                        for more details on filtering.

        :returns: A list of Instances that matched the query.
        :rtype: PaginatedList of Instance
        """
        return self.client._get_and_filter(Instance, *filters)

    def stackscripts(self, *filters, **kwargs):
        """
        Returns a list of :any:`StackScripts<StackScript>`, both public and
        private.  You may filter this query to return only
        :any:`StackScripts<StackScript>` that match certain criteria.  You may
        also request only your own private :any:`StackScripts<StackScript>`::

           my_stackscripts = client.linode.stackscripts(mine_only=True)

        API Documentation: https://techdocs.akamai.com/linode-api/reference/get-stack-scripts

        :param filters: Any number of filters to apply to this query.
                        See :doc:`Filtering Collections</linode_api4/objects/filtering>`
                        for more details on filtering.
        :param mine_only: If True, returns only private StackScripts
        :type mine_only: bool

        :returns: A list of StackScripts matching the query.
        :rtype: PaginatedList of StackScript
        """
        # python2 can't handle *args and a single keyword argument, so this is a workaround
        if "mine_only" in kwargs:
            if kwargs["mine_only"]:
                new_filter = Filter({"mine": True})
                if filters:
                    filters = list(filters)
                    filters[0] = filters[0] & new_filter
                else:
                    filters = [new_filter]

            del kwargs["mine_only"]

        if kwargs:
            raise TypeError(
                "stackscripts() got unexpected keyword argument '{}'".format(
                    kwargs.popitem()[0]
                )
            )

        return self.client._get_and_filter(StackScript, *filters)

    def kernels(self, *filters):
        """
        Returns a list of available :any:`Kernels<Kernel>`.  Kernels are used
        when creating or updating :any:`LinodeConfigs,LinodeConfig>`.

        API Documentation: https://techdocs.akamai.com/linode-api/reference/get-kernels

        :param filters: Any number of filters to apply to this query.
                        See :doc:`Filtering Collections</linode_api4/objects/filtering>`
                        for more details on filtering.

        :returns: A list of available kernels that match the query.
        :rtype: PaginatedList of Kernel
        """
        return self.client._get_and_filter(Kernel, *filters)

    # create things
    def instance_create(
        self,
        ltype,
        region,
        image=None,
        authorized_keys=None,
        firewall: Optional[Union[Firewall, int]] = None,
        backup: Optional[Union[Backup, int]] = None,
        stackscript: Optional[Union[StackScript, int]] = None,
        disk_encryption: Optional[
            Union[InstanceDiskEncryptionType, str]
        ] = None,
        placement_group: Optional[
            Union[
                InstancePlacementGroupAssignment,
                PlacementGroup,
                Dict[str, Any],
                int,
            ]
        ] = None,
        interfaces: Optional[
            List[
                Union[LinodeInterfaceOptions, NetworkInterface, Dict[str, Any]],
            ]
        ] = None,
        interface_generation: Optional[Union[InterfaceGeneration, str]] = None,
        network_helper: Optional[bool] = None,
        maintenance_policy: Optional[str] = None,
        root_pass: Optional[str] = None,
        kernel: Optional[str] = None,
        boot_size: Optional[int] = None,
        authorized_users: Optional[List[str]] = None,
        **kwargs,
    ):
        """
        Creates a new Linode Instance. This function has several modes of operation:

        **Create an Instance from an Image**

        To create an Instance from an :any:`Image`, call `instance_create` with
        a :any:`Type`, a :any:`Region`, and an :any:`Image`.  All three of
        these fields may be provided as either the ID or the appropriate object.
        When an Image is provided, at least one of ``root_pass``, ``authorized_users``, or
        ``authorized_keys`` must also be given.

        For example::

           new_linode = client.linode.instance_create(
               "g6-standard-2",
               "us-east",
               image="linode/debian13",
               root_pass="aComplex@Password123")

           ltype = client.linode.types().first()
           region = client.regions().first()
           image = client.images().first()

           another_linode = client.linode.instance_create(
               ltype,
               region,
               image=image,
               authorized_keys="ssh-rsa AAAA")

        To output the first IPv4 address of the new Linode:
            print(new_linode.ipv4[0])

        To delete the new_linode (WARNING: this immediately destroys the Linode):
            new_linode.delete()

        **Create an Instance from StackScript**

        When creating an Instance from a :any:`StackScript`, an :any:`Image` that
        the StackScript support must be provided..  You must also provide any
        required StackScript data for the script's User Defined Fields..  For
        example, if deploying `StackScript 10079`_ (which deploys a new Instance
        with a user created from keys on `github`_::

           stackscript = StackScript(client, 10079)

           new_linode = client.linode.instance_create(
              "g6-standard-2",
              "us-east",
              image="linode/debian13",
              root_pass="aComplex@Password123",
              stackscript=stackscript,
              stackscript_data={"gh_username": "example"})

        In the above example, "gh_username" is the name of a User Defined Field
        in the chosen StackScript.  For more information on StackScripts, see
        the `StackScript guide`_.

        .. _`StackScript 10079`: https://www.linode.com/stackscripts/view/10079
        .. _`github`: https://github.com
        .. _`StackScript guide`: https://www.linode.com/docs/platform/stackscripts/

        **Create an Instance from a Backup**

        To create a new Instance by restoring a :any:`Backup` to it, provide a
        :any:`Type`, a :any:`Region`, and the :any:`Backup` to restore.  You
        may provide either IDs or objects for all of these fields::

           existing_linode = Instance(client, 123)
           snapshot = existing_linode.available_backups.snapshot.current

           new_linode = client.linode.instance_create(
               "g6-standard-2",
               "us-east",
               backup=snapshot)

        **Create an Instance with explicit interfaces:**

        To create a new Instance with explicit interfaces, provide list of
        LinodeInterfaceOptions objects or dicts to the "interfaces" field::

        linode = client.linode.instance_create(
            "g6-standard-1",
            "us-mia",
            image="linode/ubuntu24.04",
            root_pass="aComplex@Password123",

            # This can be configured as an account-wide default
            interface_generation=InterfaceGeneration.LINODE,

            interfaces=[
                LinodeInterfaceOptions(
                    default_route=LinodeInterfaceDefaultRouteOptions(
                        ipv4=True,
                        ipv6=True
                    ),
                    public=LinodeInterfacePublicOptions
                )
            ]
        )

        **Create an empty Instance**

        If you want to create an empty Instance that you will configure manually,
        simply call `instance_create` with a :any:`Type` and a :any:`Region`::

           empty_linode = client.linode.instance_create("g6-standard-2", "us-east")

        When created this way, the Instance will not be booted and cannot boot
        successfully until disks and configs are created, or it is otherwise
        configured.

        API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-instance

        :param ltype: The Instance Type we are creating
        :type ltype: str or Type
        :param region: The Region in which we are creating the Instance
        :type region: str or Region
        :param image: The Image to deploy to this Instance. If this is provided,
                      at least one of root_pass, authorized_users, or authorized_keys must also be
                      provided.
        :type image: str or Image
        :param root_pass: The root password for the new Instance. Required when
                          an image is provided and neither authorized_users nor
                          authorized_keys are given.
        :type root_pass: str
        :param stackscript: The StackScript to deploy to the new Instance.  If
                            provided, "image" is required and must be compatible
                            with the chosen StackScript.
        :type stackscript: int or StackScript
        :param stackscript_data: Values for the User Defined Fields defined in
                                 the chosen StackScript.  Does nothing if
                                 StackScript is not provided.
        :type stackscript_data: dict
        :param backup: The Backup to restore to the new Instance.  May not be
                       provided if "image" is given.
        :type backup: int of Backup
        :param authorized_keys: The ssh public keys to install in the linode's
                                /root/.ssh/authorized_keys file.  Each entry may
                                be a single key, or a path to a file containing
                                the key.
        :type authorized_keys: list or str
        :param authorized_users: A list of usernames whose keys should be installed
                                 as trusted for the root user.  These user's keys
                                 should already be set up, see :any:`ProfileGroup.ssh_keys`
                                 for details.
        :type authorized_users: list[str]
        :param label: The display label for the new Instance
        :type label: str
        :param group: The display group for the new Instance
        :type group: str
        :param booted: Whether the new Instance should be booted.  This will
                       default to True if the Instance is deployed from an Image
                       or Backup.
        :type booted: bool
        :param tags: A list of tags to apply to the new instance.  If any of the
                     tags included do not exist, they will be created as part of
                     this operation.
        :type tags: list[str]
        :param private_ip: Whether the new Instance should have private networking
                           enabled and assigned a private IPv4 address.
        :type private_ip: bool
        :param metadata: Metadata-related fields to use when creating the new Instance.
                         The contents of this field can be built using the
                         :any:`build_instance_metadata` method.
        :type metadata: dict
        :param firewall: The firewall to attach this Linode to.
        :type firewall: int or Firewall
        :param disk_encryption: The disk encryption policy for this Linode.
        :type disk_encryption: InstanceDiskEncryptionType or str
        :param interfaces: An array of Network Interfaces to add to this Linode’s Configuration Profile.
                           At least one and up to three Interface objects can exist in this array.
        :type interfaces: List[LinodeInterfaceOptions], List[NetworkInterface], or List[dict[str, Any]]
        :param placement_group: A Placement Group to create this Linode under.
        :type placement_group: Union[InstancePlacementGroupAssignment, PlacementGroup, Dict[str, Any], int]
        :param interface_generation: The generation of network interfaces this Linode uses.
        :type interface_generation: InterfaceGeneration or str
        :param network_helper: Whether this instance should have Network Helper enabled.
        :type network_helper: bool
        :param maintenance_policy: The slug of the maintenance policy to apply during maintenance.
                                      If not provided, the default policy (linode/migrate) will be applied.
        :type maintenance_policy: str
        :param kernel: The kernel to boot the Instance with. If provided, this will be used as the
                       kernel for the default configuration profile.
        :type kernel: str
        :param boot_size: The size of the boot disk in MB. If provided, this will be used to create
                          the boot disk for the Instance.
        :type boot_size: int

        :returns: A new Instance object
        :rtype: Instance
        :raises ApiError: If contacting the API fails
        :raises UnexpectedResponseError: If the API response is somehow malformed.
                                         This usually indicates that you are using
                                         an outdated library.
        """

        if (
            image
            and not root_pass
            and not authorized_keys
            and not authorized_users
        ):
            raise ValueError(
                "When creating an Instance from an Image, at least one of "
                "root_pass, authorized_users, or authorized_keys must be provided."
            )

        params = {
            "type": ltype,
            "region": region,
            "image": image,
            "root_pass": root_pass,
            "authorized_keys": load_and_validate_keys(authorized_keys),
            "authorized_users": authorized_users,
            # These will automatically be flattened below
            "firewall_id": firewall,
            "backup_id": backup,
            "stackscript_id": stackscript,
            "maintenance_policy": maintenance_policy,
            # Special cases
            "disk_encryption": (
                str(disk_encryption) if disk_encryption else None
            ),
            "placement_group": (
                _expand_placement_group_assignment(placement_group)
                if placement_group
                else None
            ),
            "interfaces": interfaces,
            "interface_generation": interface_generation,
            "network_helper": network_helper,
            "kernel": kernel,
            "boot_size": boot_size,
        }

        params.update(kwargs)

        result = self.client.post(
            "/linode/instances",
            data=_flatten_request_body_recursive(drop_null_keys(params)),
        )

        if not "id" in result:
            raise UnexpectedResponseError(
                "Unexpected response when creating linode!", json=result
            )

        return Instance(self.client, result["id"], result)

    @staticmethod
    def build_instance_metadata(user_data=None, encode_user_data=True):
        """
        Builds the contents of the ``metadata`` field to be passed into
        the :any:`instance_create` method. This helper can also be used
        when cloning and rebuilding Instances.
        **Creating an Instance with User Data**::
            new_linode = client.linode.instance_create(
                "g6-standard-2",
                "us-east",
                image="linode/ubuntu22.04",
                root_pass="aComplex@Password123",
                metadata=client.linode.build_instance_metadata(user_data="myuserdata")
            )
        :param user_data: User-defined data to provide to the Linode Instance through
                          the Metadata service.
        :type user_data: str
        :param encode_user_data: If true, the provided user_data field will be automatically
                                 encoded to a valid base64 string. This field should only
                                 be set to false if the user_data param is already base64-encoded.
        :type encode_user_data: bool
        :returns: The built ``metadata`` structure.
        :rtype: dict
        """
        result = {}

        if user_data is not None:
            result["user_data"] = (
                base64.b64encode(user_data.encode()).decode()
                if encode_user_data
                else user_data
            )

        return result

    def stackscript_create(
        self, label, script, images, desc=None, public=False, **kwargs
    ):
        """
        Creates a new :any:`StackScript` on your account.

        API Documentation: https://techdocs.akamai.com/linode-api/reference/post-add-stack-script

        :param label: The label for this StackScript.
        :type label: str
        :param script: The script to run when an :any:`Instance` is deployed with
                       this StackScript.  Must begin with a shebang (#!).
        :type script: str
        :param images: A list of :any:`Images<Image>` that this StackScript
                       supports.  Instances will not be deployed from this
                       StackScript unless deployed from one of these Images.
        :type images: list of Image
        :param desc: A description for this StackScript.
        :type desc: str
        :param public: Whether this StackScript is public.  Defaults to False.
                       Once a StackScript is made public, it may not be set
                       back to private.
        :type public: bool

        :returns: The new StackScript
        :rtype: StackScript
        """

        script_body = script
        if not script.startswith("#!"):
            # it doesn't look like a stackscript body, let's see if it's a file
            script_path = Path(script)
            if script_path.is_file():
                with open(script_path) as f:
                    script_body = f.read()
            else:
                raise ValueError(
                    "script must be the script text or a path to a file"
                )

        params = {
            "label": label,
            "images": images,
            "is_public": public,
            "script": script_body,
            "description": desc if desc else "",
        }
        params.update(kwargs)

        result = self.client.post(
            "/linode/stackscripts",
            data=_flatten_request_body_recursive(params),
        )

        if not "id" in result:
            raise UnexpectedResponseError(
                "Unexpected response when creating StackScript!", json=result
            )

        s = StackScript(self.client, result["id"], result)
        return s
