#!/usr/bin/python
# -*- coding: utf-8 -*-

# Copyright: (c) 2019, Anusha Hegde <anushah@vmware.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import absolute_import, division, print_function
__metaclass__ = type


DOCUMENTATION = r'''
---
module: vmware_guest_serial_port
short_description: Manage serial ports on an existing VM
description:
  - "This module can be used to manage serial ports on an existing VM"
options:
  name:
    description:
      - Name of the virtual machine.
      - This is a required parameter, if parameter O(uuid) or O(moid) is not supplied.
    type: str
  uuid:
    description:
      - UUID of the instance to manage the serial ports, this is VMware's unique identifier.
      - This is a required parameter, if parameter O(name) or O(moid) is not supplied.
    type: str
  moid:
    description:
      - Managed Object ID of the instance to manage if known, this is a unique identifier only within a single vCenter instance.
      - This is required if O(name) or O(uuid) is not supplied.
    type: str
  use_instance_uuid:
    description:
      - Whether to use the VMware instance UUID rather than the BIOS UUID.
    default: false
    type: bool
  backings:
    type: list
    elements: dict
    description:
      - A list of backings for serial ports.
      - 'O(backings[].backing_type) is required to add or reconfigure or remove an existing serial port.'
    required: true
    suboptions:
      backing_type:
        description:
          - Backing type is required for the serial ports to be added or reconfigured or removed.
        type: str
        required: true
        aliases:
          - type
      state:
        description:
          - O(backings[].state) is required to identify whether we are adding, modifying or removing the serial port.
          - If V(present), a serial port will be added or modified.
          - If V(absent), an existing serial port will be removed.
          - If an existing serial port to modify or remove, O(backings[].backing_type) and either of O(backings[].service_uri) or O(backings[].pipe_name)
            or O(backings[].device_name) or O(backings[].file_path) are required.
        choices:
          - present
          - absent
        type: str
        default: present
      yield_on_poll:
        description:
          - Enables CPU yield behavior.
        type: bool
        default: true
      pipe_name:
        description:
          - Pipe name for the host pipe.
          - Required when O(backings[].backing_type=pipe).
        type: str
      endpoint:
        description:
          - When you use serial port pipe backing to connect a virtual machine to another process, you must define the endpoints.
          - Required when O(backings[].backing_type=pipe).
        type: str
        choices:
          - client
          - server
        default: client
      no_rx_loss:
        description:
          - Enables optimized data transfer over the pipe.
          - Required when O(backings[].backing_type=pipe).
        type: bool
        default: false
      service_uri:
        description:
          - Identifies the local host or a system on the network, depending on the value of O(backings[].direction).
          - If you use the virtual machine as a server, the URI identifies the host on which the virtual machine runs.
          - In this case, the host name part of the URI should be empty, or it should specify the address of the local host.
          - If you use the virtual machine as a client, the URI identifies the remote system on the network.
          - Required when O(backings[].backing_type=pipe).
        type: str
      proxy_uri:
        description:
          - Identifies a vSPC proxy service that provides network access to the O(backings[].service_uri).
          - If you specify a proxy URI, the virtual machine initiates a connection with the proxy service
            and forwards the serviceURI and direction to the proxy.
          - The C(Use Virtual Serial Port Concentrator) option is automatically enabled when O(backings[].proxy_uri) is set.
        type: str
        version_added: '3.7.0'
      direction:
        description:
          - The direction of the connection.
          - Required when O(backings[].backing_type=network).
        type: str
        choices:
          - client
          - server
        default: client
      device_name:
        description:
          - Serial device absolutely path.
          - Required when O(backings[].backing_type=device).
        type: str
      file_path:
        description:
          - File path for the host file used in this backing. Fully qualified path is required, like <datastore_name>/<file_name>.
          - Required when O(backings[].backing_type=file).
        type: str
extends_documentation_fragment:
- vmware.vmware.base_options
author:
  - Anusha Hegde (@anusha94)
'''

EXAMPLES = r'''
# Create serial ports
- name: Create multiple serial ports with Backing type - network, pipe, device and file
  community.vmware.vmware_guest_serial_port:
    hostname: "{{ vcenter_hostname }}"
    username: "{{ vcenter_username }}"
    password: "{{ vcenter_password }}"
    name: "test_vm1"
    backings:
    - type: 'network'
      direction: 'client'
      service_uri: 'tcp://6000'
      yield_on_poll: true
    - type: 'pipe'
      pipe_name: 'serial_pipe'
      endpoint: 'client'
    - type: 'device'
      device_name: '/dev/char/serial/uart0'
    - type: 'file'
      file_path: '[datastore1]/file1'
      yield_on_poll:  true
    register: create_multiple_ports

# Create vSPC port
- name: Create network serial port with vSPC
  community.vmware.vmware_guest_serial_port:
    hostname: "{{ vcenter_hostname }}"
    username: "{{ vcenter_username }}"
    password: "{{ vcenter_password }}"
    name: "test_vm1"
    backings:
    - type: 'network'
      direction: 'server'
      service_uri: 'vSPC.py'
      proxy_uri: 'telnets://<host>:<port>'
      yield_on_poll: true

# Modify existing serial port
- name: Modify Network backing type
  community.vmware.vmware_guest_serial_port:
    hostname: '{{ vcenter_hostname }}'
    username: '{{ vcenter_username }}'
    password: '{{ vcenter_password }}'
    name: '{{ name }}'
    backings:
    - type: 'network'
      state: 'present'
      direction: 'server'
      service_uri: 'tcp://6000'
  delegate_to: localhost

# Remove serial port
- name: Remove pipe backing type
  community.vmware.vmware_guest_serial_port:
    hostname: '{{ vcenter_hostname }}'
    username: '{{ vcenter_username }}'
    password: '{{ vcenter_password }}'
    name: '{{ name }}'
    backings:
    - type: 'pipe'
      state: 'absent'
  delegate_to: localhost
'''

RETURN = r'''
serial_port_data:
    description: metadata about the virtual machine's serial ports after managing them
    returned: always
    type: dict
    sample: [
        {
          "backing_type": "network",
          "direction": "client",
          "service_uri": "tcp://6000"
        },
        {
          "backing_type": "pipe",
          "direction": "server",
          "pipe_name": "serial pipe"
        },
    ]
'''

from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.vmware.plugins.module_utils.vmware import PyVmomi, wait_for_task
from ansible_collections.vmware.vmware.plugins.module_utils.argument_spec import base_argument_spec
from ansible.module_utils.common.text.converters import to_native
try:
    from pyVmomi import vim
except ImportError:
    pass


class PyVmomiHelper(PyVmomi):
    """ This class is a helper to create easily VMware Spec for PyVmomiHelper """

    def __init__(self, module):
        super(PyVmomiHelper, self).__init__(module)
        self.change_applied = False   # a change was applied meaning at least one task succeeded
        self.config_spec = vim.vm.ConfigSpec()
        self.config_spec.deviceChange = []
        self.serial_ports = []

    def check_vm_state(self, vm_obj):
        """
        To add serial port, the VM must be in powered off state

        Input:
          - vm: Virtual Machine

        Output:
          - True if vm is in poweredOff state
          - module fails otherwise
        """
        if vm_obj.runtime.powerState == vim.VirtualMachinePowerState.poweredOff:
            return True
        self.module.fail_json(msg="A serial device cannot be added to a VM in the current state(" + vm_obj.runtime.powerState + "). "
                                  "Please use the vmware_guest_powerstate module to power off the VM")

    def get_serial_port_config_spec(self, vm_obj):
        """
        Variables changed:
          - self.config_spec
          - self.change_applied
        """
        # create serial config spec for adding, editing, removing
        for backing in self.params.get('backings'):
            serial_port = get_serial_port(vm_obj, backing)
            if serial_port:
                serial_spec = vim.vm.device.VirtualDeviceSpec()
                serial_spec.device = serial_port
                if diff_serial_port_config(serial_port, backing):
                    if backing['state'] == 'present':
                        # modify existing serial port
                        serial_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.edit
                        backing_type = backing.get('type', backing.get('backing_type'))
                        serial_spec.device.backing = self.get_backing_info(serial_port, backing, backing_type)
                        serial_spec.device.yieldOnPoll = backing['yield_on_poll']
                        self.change_applied = True
                        self.config_spec.deviceChange.append(serial_spec)
                    elif backing['state'] == 'absent':
                        # remove serial port
                        serial_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.remove
                        self.change_applied = True
                        self.config_spec.deviceChange.append(serial_spec)
            else:
                if backing['state'] == 'present':
                    # if serial port is None
                    # create a new serial port
                    serial_port_spec = self.create_serial_port(backing)
                    serial_port_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add
                    self.serial_ports.append(serial_port_spec)
                    self.change_applied = True

    def reconfigure_vm_serial_port(self, vm_obj):
        """
        Reconfigure vm with new or modified serial port config spec
        """
        self.get_serial_port_config_spec(vm_obj)
        try:
            # configure create tasks first
            if self.serial_ports:
                for serial_port in self.serial_ports:
                    # each type of serial port is of config_spec.device = vim.vm.device.VirtualSerialPort() object type
                    # because serial ports differ in the backing types and config_spec.device has to be unique,
                    # we are creating a new spec for every create port configuration
                    spec = vim.vm.ConfigSpec()
                    spec.deviceChange.append(serial_port)
                    task = vm_obj.ReconfigVM_Task(spec=spec)
                    wait_for_task(task)
            task = vm_obj.ReconfigVM_Task(spec=self.config_spec)
            wait_for_task(task)
        except vim.fault.InvalidDatastorePath as e:
            self.module.fail_json(msg="Failed to configure serial port on given virtual machine due to invalid path: %s" % to_native(e.msg))
        except vim.fault.RestrictedVersion as e:
            self.module.fail_json(msg="Failed to reconfigure virtual machine due to product versioning restrictions: %s" % to_native(e.msg))
        if task.info.state == 'error':
            results = {'changed': self.change_applied, 'failed': True, 'msg': task.info.error.msg}
        else:
            serial_port_info = get_serial_port_info(vm_obj)
            results = {'changed': self.change_applied, 'failed': False, 'serial_port_info': serial_port_info}

        return results

    def set_network_backing(self, serial_port, backing_info):
        """
        Set the networking backing params
        """
        required_params = ['service_uri', 'direction']
        if set(required_params).issubset(backing_info.keys()):
            backing = serial_port.URIBackingInfo()
            backing.serviceURI = backing_info['service_uri']
            backing.proxyURI = backing_info['proxy_uri']
            backing.direction = backing_info['direction']
        else:
            self.module.fail_json(msg="Failed to create a new serial port of network backing type due to insufficient parameters."
                                  + "The required parameters are service_uri and direction")
        return backing

    def set_pipe_backing(self, serial_port, backing_info):
        """
        Set the pipe backing params
        """
        required_params = ['pipe_name', 'endpoint']
        if set(required_params).issubset(backing_info.keys()):
            backing = serial_port.PipeBackingInfo()
            backing.pipeName = backing_info['pipe_name']
            backing.endpoint = backing_info['endpoint']
        else:
            self.module.fail_json(msg="Failed to create a new serial port of pipe backing type due to insufficient parameters."
                                  + "The required parameters are pipe_name and endpoint")

        # since no_rx_loss is an optional argument, so check if the key is present
        if 'no_rx_loss' in backing_info.keys() and backing_info['no_rx_loss']:
            backing.noRxLoss = backing_info['no_rx_loss']
        return backing

    def set_device_backing(self, serial_port, backing_info):
        """
        Set the device backing params
        """
        required_params = ['device_name']
        if set(required_params).issubset(backing_info.keys()):
            backing = serial_port.DeviceBackingInfo()
            backing.deviceName = backing_info['device_name']
        else:
            self.module.fail_json(msg="Failed to create a new serial port of device backing type due to insufficient parameters."
                                  + "The required parameters are device_name")
        return backing

    def set_file_backing(self, serial_port, backing_info):
        """
        Set the file backing params
        """
        required_params = ['file_path']
        if set(required_params).issubset(backing_info.keys()):
            backing = serial_port.FileBackingInfo()
            backing.fileName = backing_info['file_path']
        else:
            self.module.fail_json(msg="Failed to create a new serial port of file backing type due to insufficient parameters."
                                  + "The required parameters are file_path")
        return backing

    def get_backing_info(self, serial_port, backing, backing_type):
        """
        Returns the call to the appropriate backing function based on the backing type
        """
        switcher = {
            "network": self.set_network_backing,
            "pipe": self.set_pipe_backing,
            "device": self.set_device_backing,
            "file": self.set_file_backing
        }
        backing_func = switcher.get(backing_type, None)
        if backing_func is None:
            self.module.fail_json(msg="Failed to find a valid backing type. "
                                      "Provided '%s', should be one of [%s]" % (backing_type, ', '.join(switcher.keys())))
        return backing_func(serial_port, backing)

    def create_serial_port(self, backing):
        """
        Create a new serial port
        """
        serial_spec = vim.vm.device.VirtualDeviceSpec()
        serial_port = vim.vm.device.VirtualSerialPort()
        serial_port.yieldOnPoll = backing['yield_on_poll']
        backing_type = backing.get('type', backing.get('backing_type', None))
        serial_port.backing = self.get_backing_info(serial_port, backing, backing_type)
        serial_spec.device = serial_port
        return serial_spec


def get_serial_port(vm_obj, backing):
    """
    Return the serial port of specified backing type
    """
    serial_port = None
    backing_type_mapping = {
        'network': vim.vm.device.VirtualSerialPort.URIBackingInfo,
        'pipe': vim.vm.device.VirtualSerialPort.PipeBackingInfo,
        'device': vim.vm.device.VirtualSerialPort.DeviceBackingInfo,
        'file': vim.vm.device.VirtualSerialPort.FileBackingInfo
    }
    valid_params = backing.keys()
    for device in vm_obj.config.hardware.device:
        if isinstance(device, vim.vm.device.VirtualSerialPort):
            backing_type = backing.get('type', backing.get('backing_type', None))
            if isinstance(device.backing, backing_type_mapping[backing_type]):
                if 'service_uri' in valid_params:
                    # network backing type
                    serial_port = device
                    break
                if 'pipe_name' in valid_params:
                    # named pipe backing type
                    serial_port = device
                    break
                if 'device_name' in valid_params:
                    # physical serial device backing type
                    serial_port = device
                    break
                if 'file_path' in valid_params:
                    # file backing type
                    serial_port = device
                    break
                # if there is a backing of only one type, user need not provide secondary details like service_uri, pipe_name, device_name or file_path
                # we will match the serial port with backing type only
                # in this case, the last matching serial port will be returned
                serial_port = device
    return serial_port


def get_serial_port_info(vm_obj):
    """
    Get the serial port info
    """
    serial_port_info = []
    if vm_obj is None:
        return serial_port_info
    for port in vm_obj.config.hardware.device:
        backing = dict()
        if isinstance(port, vim.vm.device.VirtualSerialPort):
            if isinstance(port.backing, vim.vm.device.VirtualSerialPort.URIBackingInfo):
                backing['backing_type'] = 'network'
                backing['direction'] = port.backing.direction
                backing['service_uri'] = port.backing.serviceURI
                backing['proxy_uri'] = port.backing.proxyURI
            elif isinstance(port.backing, vim.vm.device.VirtualSerialPort.PipeBackingInfo):
                backing['backing_type'] = 'pipe'
                backing['pipe_name'] = port.backing.pipeName
                backing['endpoint'] = port.backing.endpoint
                backing['no_rx_loss'] = port.backing.noRxLoss
            elif isinstance(port.backing, vim.vm.device.VirtualSerialPort.DeviceBackingInfo):
                backing['backing_type'] = 'device'
                backing['device_name'] = port.backing.deviceName
            elif isinstance(port.backing, vim.vm.device.VirtualSerialPort.FileBackingInfo):
                backing['backing_type'] = 'file'
                backing['file_path'] = port.backing.fileName
            else:
                continue
            serial_port_info.append(backing)
    return serial_port_info


def diff_serial_port_config(serial_port, backing):
    if backing['state'] == 'present':
        if 'yield_on_poll' in backing:
            if serial_port.yieldOnPoll != backing['yield_on_poll']:
                return True
        if backing['service_uri'] is not None:
            if serial_port.backing.serviceURI != backing['service_uri'] or \
                    serial_port.backing.direction != backing['direction'] or \
                    serial_port.backing.proxyURI != backing['proxy_uri']:
                return True
        if backing['pipe_name'] is not None:
            if serial_port.backing.pipeName != backing['pipe_name'] or \
                    serial_port.backing.endpoint != backing['endpoint'] or \
                    serial_port.backing.noRxLoss != backing['no_rx_loss']:
                return True
        if backing['device_name'] is not None:
            if serial_port.backing.deviceName != backing['device_name']:
                return True
        if backing['file_path'] is not None:
            if serial_port.backing.fileName != backing['file_path']:
                return True

    if backing['state'] == 'absent':
        if backing['service_uri'] is not None:
            if serial_port.backing.serviceURI == backing['service_uri'] and \
                    serial_port.backing.proxyURI == backing['proxy_uri']:
                return True
        if backing['pipe_name'] is not None:
            if serial_port.backing.pipeName == backing['pipe_name']:
                return True
        if backing['device_name'] is not None:
            if serial_port.backing.deviceName == backing['device_name']:
                return True
        if backing['file_path'] is not None:
            if serial_port.backing.fileName == backing['file_path']:
                return True

    return False


def main():
    """
    Main method
    """
    argument_spec = base_argument_spec()
    argument_spec.update(
        name=dict(type='str'),
        uuid=dict(type='str'),
        moid=dict(type='str'),
        use_instance_uuid=dict(type='bool', default=False),
        backings=dict(type='list', elements='dict', required=True,
                      options=dict(
                          backing_type=dict(type='str', required=True, aliases=['type']),
                          pipe_name=dict(type='str', default=None),
                          endpoint=dict(type='str', choices=['client', 'server'], default='client'),
                          no_rx_loss=dict(type='bool', default=False),
                          service_uri=dict(type='str', default=None),
                          proxy_uri=dict(type='str', default=None),
                          direction=dict(type='str', choices=['client', 'server'], default='client'),
                          device_name=dict(type='str', default=None),
                          file_path=dict(type='str', default=None),
                          yield_on_poll=dict(type='bool', default=True),
                          state=dict(type='str', choices=['present', 'absent'], default='present')
                      ),
                      required_if=[
                          ['backing_type', 'pipe', ['pipe_name', 'endpoint', 'no_rx_loss']],
                          ['backing_type', 'network', ['service_uri', 'direction']],
                          ['backing_type', 'device', ['device_name']],
                          ['backing_type', 'file', ['file_path']]
                      ]),
    )

    module = AnsibleModule(
        argument_spec=argument_spec,
        required_one_of=[
            ['name', 'uuid', 'moid']
        ],
        mutually_exclusive=[
            ['name', 'uuid', 'moid']
        ],
    )
    result = {'failed': False, 'changed': False}

    pyv = PyVmomiHelper(module)
    # Check if the VM exists before continuing
    vm_obj = pyv.get_vm()

    if vm_obj:
        proceed = pyv.check_vm_state(vm_obj)
        if proceed:
            result = pyv.reconfigure_vm_serial_port(vm_obj)

    else:
        # We are unable to find the virtual machine user specified
        # Bail out
        vm_id = (module.params.get('name') or module.params.get('uuid') or module.params.get('vm_id'))
        module.fail_json(msg="Unable to manage serial ports for non-existing"
                             " virtual machine '%s'." % vm_id)

    if result['failed']:
        module.fail_json(**result)
    else:
        module.exit_json(**result)


if __name__ == '__main__':
    main()
