"""
Manage Parallels Desktop VMs with ``prlctl`` and ``prlsrvctl``.  Only some of
the prlctl commands implemented so far.  Of those that have been implemented,
not all of the options may have been provided yet.  For a complete reference,
see the `Parallels Desktop Reference Guide
<http://download.parallels.com/desktop/v9/ga/docs/en_US/Parallels%20Command%20Line%20Reference%20Guide.pdf>`_.

This module requires the prlctl binary to be installed to run most functions.
To run parallels.prlsrvctl, the prlsrvctl binary is required.

What has not been implemented yet can be accessed through ``parallels.prlctl``
and ``parallels.prlsrvctl`` (note the preceding double dash ``--`` as
necessary):

.. code-block:: bash

    salt '*' parallels.prlctl installtools macvm runas=macdev
    salt -- '*' parallels.prlctl capture 'macvm --file macvm.display.png' runas=macdev
    salt -- '*' parallels.prlsrvctl set '--mem-limit auto' runas=macdev

.. versionadded:: 2016.3.0
"""

import logging
import re
import shlex

import salt.utils.data
import salt.utils.path
import salt.utils.yaml
from salt.exceptions import CommandExecutionError, SaltInvocationError

__virtualname__ = "parallels"
__func_alias__ = {
    "exec_": "exec",
}
log = logging.getLogger(__name__)
# Match any GUID
GUID_REGEX = re.compile(
    r"{?([0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})}?", re.I
)


def _normalize_args(args):
    """
    Return args as a list of strings
    """
    if isinstance(args, str):
        return shlex.split(args)

    if isinstance(args, (tuple, list)):
        return [str(arg) for arg in args]
    else:
        return [str(args)]


def _find_guids(guid_string):
    """
    Return the set of GUIDs found in guid_string

    :param str guid_string:
        String containing zero or more GUIDs.  Each GUID may or may not be
        enclosed in {}

    Example data (this string contains two distinct GUIDs):

    PARENT_SNAPSHOT_ID                      SNAPSHOT_ID
                                            {a5b8999f-5d95-4aff-82de-e515b0101b66}
    {a5b8999f-5d95-4aff-82de-e515b0101b66} *{a7345be5-ab66-478c-946e-a6c2caf14909}
    """
    guids = []
    for found_guid in re.finditer(GUID_REGEX, guid_string):
        if found_guid.groups():
            guids.append(found_guid.group(0).strip("{}"))
    return sorted(list(set(guids)))


def prlsrvctl(sub_cmd, args=None, runas=None):
    """
    Execute a prlsrvctl command

    .. versionadded:: 2016.11.0

    :param str sub_cmd:
        prlsrvctl subcommand to execute

    :param str args:
        The arguments supplied to ``prlsrvctl <sub_cmd>``

    :param str runas:
        The user that the prlsrvctl command will be run as

    Example:

    .. code-block:: bash

        salt '*' parallels.prlsrvctl info runas=macdev
        salt '*' parallels.prlsrvctl usb list runas=macdev
        salt -- '*' parallels.prlsrvctl set '--mem-limit auto' runas=macdev
    """
    if not salt.utils.path.which("prlsrvctl"):
        raise CommandExecutionError("prlsrvctl utility not available")

    # Construct command
    cmd = ["prlsrvctl", sub_cmd]
    if args:
        cmd.extend(_normalize_args(args))

    # Execute command and return output
    return __salt__["cmd.run"](cmd, runas=runas)


def prlctl(sub_cmd, args=None, runas=None):
    """
    Execute a prlctl command

    :param str sub_cmd:
        prlctl subcommand to execute

    :param str args:
        The arguments supplied to ``prlctl <sub_cmd>``

    :param str runas:
        The user that the prlctl command will be run as

    Example:

    .. code-block:: bash

        salt '*' parallels.prlctl user list runas=macdev
        salt '*' parallels.prlctl exec 'macvm uname' runas=macdev
        salt -- '*' parallels.prlctl capture 'macvm --file macvm.display.png' runas=macdev
    """
    if not salt.utils.path.which("prlctl"):
        raise CommandExecutionError("prlctl utility not available")

    # Construct command
    cmd = ["prlctl", sub_cmd]
    if args:
        cmd.extend(_normalize_args(args))

    # Execute command and return output
    return __salt__["cmd.run"](cmd, runas=runas)


def list_vms(name=None, info=False, all=False, args=None, runas=None, template=False):
    """
    List information about the VMs

    :param str name:
        Name/ID of VM to list

        .. versionchanged:: 2016.11.0

            No longer implies ``info=True``

    :param str info:
        List extra information

    :param bool all:
        List all non-template VMs

    :param tuple args:
        Additional arguments given to ``prctl list``

    :param str runas:
        The user that the prlctl command will be run as

    :param bool template:
        List the available virtual machine templates.  The real virtual
        machines will not be included in the output

        .. versionadded:: 2016.11.0

    Example:

    .. code-block:: bash

        salt '*' parallels.list_vms runas=macdev
        salt '*' parallels.list_vms name=macvm info=True runas=macdev
        salt '*' parallels.list_vms info=True runas=macdev
        salt '*' parallels.list_vms ' -o uuid,status' all=True runas=macdev
    """
    # Construct argument list
    if args is None:
        args = []
    else:
        args = _normalize_args(args)

    if name:
        args.extend([name])
    if info:
        args.append("--info")
    if all:
        args.append("--all")
    if template:
        args.append("--template")

    # Execute command and return output
    return prlctl("list", args, runas=runas)


def clone(name, new_name, linked=False, template=False, runas=None):
    """
    Clone a VM

    .. versionadded:: 2016.11.0

    :param str name:
        Name/ID of VM to clone

    :param str new_name:
        Name of the new VM

    :param bool linked:
        Create a linked virtual machine.

    :param bool template:
        Create a virtual machine template instead of a real virtual machine.

    :param str runas:
        The user that the prlctl command will be run as

    Example:

    .. code-block:: bash

        salt '*' parallels.clone macvm macvm_new runas=macdev
        salt '*' parallels.clone macvm macvm_templ template=True runas=macdev
    """
    args = [salt.utils.data.decode(name), "--name", salt.utils.data.decode(new_name)]
    if linked:
        args.append("--linked")
    if template:
        args.append("--template")
    return prlctl("clone", args, runas=runas)


def delete(name, runas=None):
    """
    Delete a VM

    .. versionadded:: 2016.11.0

    :param str name:
        Name/ID of VM to clone

    :param str runas:
        The user that the prlctl command will be run as

    Example:

    .. code-block:: bash

        salt '*' parallels.exec macvm 'find /etc/paths.d' runas=macdev
    """
    return prlctl("delete", salt.utils.data.decode(name), runas=runas)


def exists(name, runas=None):
    """
    Query whether a VM exists

    .. versionadded:: 2016.11.0

    :param str name:
        Name/ID of VM

    :param str runas:
        The user that the prlctl command will be run as

    Example:

    .. code-block:: bash

        salt '*' parallels.exists macvm runas=macdev
    """
    vm_info = list_vms(name, info=True, runas=runas).splitlines()
    for info_line in vm_info:
        if f"Name: {name}" in info_line:
            return True
    return False


def start(name, runas=None):
    """
    Start a VM

    :param str name:
        Name/ID of VM to start

    :param str runas:
        The user that the prlctl command will be run as

    Example:

    .. code-block:: bash

        salt '*' parallels.start macvm runas=macdev
    """
    return prlctl("start", salt.utils.data.decode(name), runas=runas)


def stop(name, kill=False, runas=None):
    """
    Stop a VM

    :param str name:
        Name/ID of VM to stop

    :param bool kill:
        Perform a hard shutdown

    :param str runas:
        The user that the prlctl command will be run as

    Example:

    .. code-block:: bash

        salt '*' parallels.stop macvm runas=macdev
        salt '*' parallels.stop macvm kill=True runas=macdev
    """
    # Construct argument list
    args = [salt.utils.data.decode(name)]
    if kill:
        args.append("--kill")

    # Execute command and return output
    return prlctl("stop", args, runas=runas)


def restart(name, runas=None):
    """
    Restart a VM by gracefully shutting it down and then restarting
    it

    :param str name:
        Name/ID of VM to restart

    :param str runas:
        The user that the prlctl command will be run as

    Example:

    .. code-block:: bash

        salt '*' parallels.restart macvm runas=macdev
    """
    return prlctl("restart", salt.utils.data.decode(name), runas=runas)


def reset(name, runas=None):
    """
    Reset a VM by performing a hard shutdown and then a restart

    :param str name:
        Name/ID of VM to reset

    :param str runas:
        The user that the prlctl command will be run as

    Example:

    .. code-block:: bash

        salt '*' parallels.reset macvm runas=macdev
    """
    return prlctl("reset", salt.utils.data.decode(name), runas=runas)


def status(name, runas=None):
    """
    Status of a VM

    :param str name:
        Name/ID of VM whose status will be returned

    :param str runas:
        The user that the prlctl command will be run as

    Example:

    .. code-block:: bash

        salt '*' parallels.status macvm runas=macdev
    """
    return prlctl("status", salt.utils.data.decode(name), runas=runas)


def exec_(name, command, runas=None):
    """
    Run a command on a VM

    :param str name:
        Name/ID of VM whose exec will be returned

    :param str command:
        Command to run on the VM

    :param str runas:
        The user that the prlctl command will be run as

    Example:

    .. code-block:: bash

        salt '*' parallels.exec macvm 'find /etc/paths.d' runas=macdev
    """
    # Construct argument list
    args = [salt.utils.data.decode(name)]
    args.extend(_normalize_args(command))

    # Execute command and return output
    return prlctl("exec", args, runas=runas)


def snapshot_id_to_name(name, snap_id, strict=False, runas=None):
    """
    Attempt to convert a snapshot ID to a snapshot name.  If the snapshot has
    no name or if the ID is not found or invalid, an empty string will be returned

    :param str name:
        Name/ID of VM whose snapshots are inspected

    :param str snap_id:
        ID of the snapshot

    :param bool strict:
        Raise an exception if a name cannot be found for the given ``snap_id``

    :param str runas:
        The user that the prlctl command will be run as

    Example data

    .. code-block:: yaml

        ID: {a5b8999f-5d95-4aff-82de-e515b0101b66}
        Name: original
        Date: 2016-03-04 10:50:34
        Current: yes
        State: poweroff
        Description: original state

    CLI Example:

    .. code-block:: bash

        salt '*' parallels.snapshot_id_to_name macvm a5b8999f-5d95-4aff-82de-e515b0101b66 runas=macdev
    """
    # Validate VM name and snapshot ID
    name = salt.utils.data.decode(name)
    if not re.match(GUID_REGEX, snap_id):
        raise SaltInvocationError(
            f'Snapshot ID "{salt.utils.data.decode(snap_id)}" is not a GUID'
        )

    # Get the snapshot information of the snapshot having the requested ID
    info = prlctl("snapshot-list", [name, "--id", snap_id], runas=runas)

    # Parallels desktop returned no information for snap_id
    if not info:
        raise SaltInvocationError(f'No snapshots for VM "{name}" have ID "{snap_id}"')

    # Try to interpret the information
    try:
        data = salt.utils.yaml.safe_load(info)
    except salt.utils.yaml.YAMLError as err:
        log.warning("Could not interpret snapshot data returned from prlctl: %s", err)
        data = {}

    # Find the snapshot name
    if isinstance(data, dict):
        snap_name = data.get("Name", "")
        # If snapshot name is of type NoneType, then the snapshot is unnamed
        if snap_name is None:
            snap_name = ""
    else:
        log.warning(
            "Could not interpret snapshot data returned from prlctl: "
            "data is not formed as a dictionary: %s",
            data,
        )
        snap_name = ""

    # Raise or return the result
    if not snap_name and strict:
        raise SaltInvocationError(
            'Could not find a snapshot name for snapshot ID "{}" of VM "{}"'.format(
                snap_id, name
            )
        )
    return salt.utils.data.decode(snap_name)


def snapshot_name_to_id(name, snap_name, strict=False, runas=None):
    """
    Attempt to convert a snapshot name to a snapshot ID.  If the name is not
    found an empty string is returned.  If multiple snapshots share the same
    name, a list will be returned

    :param str name:
        Name/ID of VM whose snapshots are inspected

    :param str snap_name:
        Name of the snapshot

    :param bool strict:
        Raise an exception if multiple snapshot IDs are found

    :param str runas:
        The user that the prlctl command will be run as

    CLI Example:

    .. code-block:: bash

        salt '*' parallels.snapshot_id_to_name macvm original runas=macdev
    """
    # Validate VM and snapshot names
    name = salt.utils.data.decode(name)
    snap_name = salt.utils.data.decode(snap_name)

    # Get a multiline string containing all the snapshot GUIDs
    info = prlctl("snapshot-list", name, runas=runas)

    # Get a set of all snapshot GUIDs in the string
    snap_ids = _find_guids(info)

    # Try to match the snapshot name to an ID
    named_ids = []
    for snap_id in snap_ids:
        if snapshot_id_to_name(name, snap_id, runas=runas) == snap_name:
            named_ids.append(snap_id)

    # Return one or more IDs having snap_name or raise an error upon
    # non-singular names
    if not named_ids:
        raise SaltInvocationError(
            f'No snapshots for VM "{name}" have name "{snap_name}"'
        )
    elif len(named_ids) == 1:
        return named_ids[0]
    else:
        multi_msg = 'Multiple snapshots for VM "{}" have name "{}"'.format(
            name, snap_name
        )
        if strict:
            raise SaltInvocationError(multi_msg)
        else:
            log.warning(multi_msg)
        return named_ids


def _validate_snap_name(name, snap_name, strict=True, runas=None):
    """
    Validate snapshot name and convert to snapshot ID

    :param str name:
        Name/ID of VM whose snapshot name is being validated

    :param str snap_name:
        Name/ID of snapshot

    :param bool strict:
        Raise an exception if multiple snapshot IDs are found

    :param str runas:
        The user that the prlctl command will be run as
    """
    snap_name = salt.utils.data.decode(snap_name)

    # Try to convert snapshot name to an ID without {}
    if re.match(GUID_REGEX, snap_name):
        return snap_name.strip("{}")
    else:
        return snapshot_name_to_id(name, snap_name, strict=strict, runas=runas)


def list_snapshots(name, snap_name=None, tree=False, names=False, runas=None):
    """
    List the snapshots

    :param str name:
        Name/ID of VM whose snapshots will be listed

    :param str snap_id:
        Name/ID of snapshot to display information about.  If ``tree=True`` is
        also specified, display the snapshot subtree having this snapshot as
        the root snapshot

    :param bool tree:
        List snapshots in tree format rather than tabular format

    :param bool names:
        List snapshots as ID, name pairs

    :param str runas:
        The user that the prlctl command will be run as

    Example:

    .. code-block:: bash

        salt '*' parallels.list_snapshots macvm runas=macdev
        salt '*' parallels.list_snapshots macvm tree=True runas=macdev
        salt '*' parallels.list_snapshots macvm snap_name=original runas=macdev
        salt '*' parallels.list_snapshots macvm names=True runas=macdev
    """
    # Validate VM and snapshot names
    name = salt.utils.data.decode(name)
    if snap_name:
        snap_name = _validate_snap_name(name, snap_name, runas=runas)

    # Construct argument list
    args = [name]
    if tree:
        args.append("--tree")
    if snap_name:
        args.extend(["--id", snap_name])

    # Execute command
    res = prlctl("snapshot-list", args, runas=runas)

    # Construct ID, name pairs
    if names:
        # Find all GUIDs in the result
        snap_ids = _find_guids(res)

        # Try to find the snapshot names
        ret = "{:<38}  {}\n".format("Snapshot ID", "Snapshot Name")
        for snap_id in snap_ids:
            snap_name = snapshot_id_to_name(name, snap_id, runas=runas)
            ret += f"{{{snap_id}}}  {salt.utils.data.decode(snap_name)}\n"
        return ret

    # Return information directly from parallels desktop
    else:
        return res


def snapshot(name, snap_name=None, desc=None, runas=None):
    """
    Create a snapshot

    :param str name:
        Name/ID of VM to take a snapshot of

    :param str snap_name:
        Name of snapshot

    :param str desc:
        Description of snapshot

    :param str runas:
        The user that the prlctl command will be run as

    Example:

    .. code-block:: bash

        salt '*' parallels.create_snapshot macvm snap_name=macvm-original runas=macdev
        salt '*' parallels.create_snapshot macvm snap_name=macvm-updates desc='clean install with updates' runas=macdev
    """
    # Validate VM and snapshot names
    name = salt.utils.data.decode(name)
    if snap_name:
        snap_name = salt.utils.data.decode(snap_name)

    # Construct argument list
    args = [name]
    if snap_name:
        args.extend(["--name", snap_name])
    if desc:
        args.extend(["--description", desc])

    # Execute command and return output
    return prlctl("snapshot", args, runas=runas)


def delete_snapshot(name, snap_name, runas=None, all=False):
    """
    Delete a snapshot

    .. note::

        Deleting a snapshot from which other snapshots are dervied will not
        delete the derived snapshots

    :param str name:
        Name/ID of VM whose snapshot will be deleted

    :param str snap_name:
        Name/ID of snapshot to delete

    :param str runas:
        The user that the prlctl command will be run as

    :param bool all:
        Delete all snapshots having the name given

        .. versionadded:: 2016.11.0

    Example:

    .. code-block:: bash

        salt '*' parallels.delete_snapshot macvm 'unneeded snapshot' runas=macdev
        salt '*' parallels.delete_snapshot macvm 'Snapshot for linked clone' all=True runas=macdev
    """
    # strict means raise an error if multiple snapshot IDs found for the name given
    strict = not all

    # Validate VM and snapshot names
    name = salt.utils.data.decode(name)
    snap_ids = _validate_snap_name(name, snap_name, strict=strict, runas=runas)
    if isinstance(snap_ids, str):
        snap_ids = [snap_ids]

    # Delete snapshot(s)
    ret = {}
    for snap_id in snap_ids:
        snap_id = snap_id.strip("{}")
        # Construct argument list
        args = [name, "--id", snap_id]

        # Execute command
        ret[snap_id] = prlctl("snapshot-delete", args, runas=runas)

    # Return results
    ret_keys = list(ret.keys())
    if len(ret_keys) == 1:
        return ret[ret_keys[0]]
    else:
        return ret


def revert_snapshot(name, snap_name, runas=None):
    """
    Revert a VM to a snapshot

    :param str name:
        Name/ID of VM to revert to a snapshot

    :param str snap_name:
        Name/ID of snapshot to revert to

    :param str runas:
        The user that the prlctl command will be run as

    Example:

    .. code-block:: bash

        salt '*' parallels.revert_snapshot macvm base-with-updates runas=macdev
    """
    # Validate VM and snapshot names
    name = salt.utils.data.decode(name)
    snap_name = _validate_snap_name(name, snap_name, runas=runas)

    # Construct argument list
    args = [name, "--id", snap_name]

    # Execute command and return output
    return prlctl("snapshot-switch", args, runas=runas)
