#!/usr/bin/python

# Copyright (c) 2019, Nurfet Becirevic <nurfet.becirevic@gmail.com>
# Copyright (c) 2017, Tomas Karasek <tom.to.the.k@gmail.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 annotations

DOCUMENTATION = r"""
module: packet_ip_subnet

short_description: Assign IP subnet to a bare metal server

description:
  - Assign or unassign IPv4 or IPv6 subnets to or from a device in the Packet host.
  - IPv4 subnets must come from already reserved block.
  - IPv6 subnets must come from publicly routable /56 block from your project.
  - See U(https://support.packet.com/kb/articles/elastic-ips) for more info on IP block reservation.
version_added: '0.2.0'

author:
  - Tomas Karasek (@t0mk) <tom.to.the.k@gmail.com>
  - Nurfet Becirevic (@nurfet-becirevic) <nurfet.becirevic@gmail.com>

extends_documentation_fragment:
  - community.general._attributes

attributes:
  check_mode:
    support: full
  diff_mode:
    support: none

options:
  auth_token:
    description:
      - Packet API token. You can also supply it in environment variable E(PACKET_API_TOKEN).
    type: str

  hostname:
    description:
      - A hostname of a device to/from which to assign/remove a subnet.
    type: str

  device_id:
    description:
      - UUID of a device to/from which to assign/remove a subnet.
    type: str

  project_id:
    description:
      - UUID of a project of the device to/from which to assign/remove a subnet.
    type: str

  device_count:
    description:
      - The number of devices to retrieve from the project. The max allowed value is 1000.
      - See U(https://www.packet.com/developers/api/#retrieve-all-devices-of-a-project) for more info.
    default: 100
    type: int

  cidr:
    description:
      - IPv4 or IPv6 subnet which you want to manage. It must come from a reserved block for your project in the Packet Host.
    aliases: [name]
    type: str
    required: true

  state:
    description:
      - Desired state of the IP subnet on the specified device.
      - With O(state=present), you must specify either O(hostname) or O(device_id). Subnet with given CIDR is then assigned
        to the specified device.
      - With O(state=absent), you can specify either O(hostname) or O(device_id). The subnet is then removed from specified
        devices.
      - If you leave both O(hostname) and O(device_id) empty, the subnet is then removed from any device it is assigned to.
    choices: ['present', 'absent']
    default: 'present'
    type: str

requirements:
  - "packet-python >= 1.35"
"""

EXAMPLES = r"""
# All the examples assume that you have your Packet API token in env var PACKET_API_TOKEN.
# You can also pass it to the auth_token parameter of the module instead.

- name: Create 1 device and assign an arbitrary public IPv4 subnet to it
  hosts: localhost
  tasks:

    - packet_device:
        project_id: 89b497ee-5afc-420a-8fb5-56984898f4df
        hostnames: myserver
        operating_system: ubuntu_16_04
        plan: baremetal_0
        facility: sjc1
        state: active

# Pick an IPv4 address from a block allocated to your project.

    - community.general.packet_ip_subnet:
        project_id: 89b497ee-5afc-420a-8fb5-56984898f4df
        hostname: myserver
        cidr: "147.75.201.78/32"

# Release IP address 147.75.201.78

- name: Unassign IP address from any device in your project
  hosts: localhost
  tasks:
    - community.general.packet_ip_subnet:
        project_id: 89b497ee-5afc-420a-8fb5-56984898f4df
        cidr: "147.75.201.78/32"
        state: absent
"""

RETURN = r"""
device_id:
  type: str
  description: UUID of the device associated with the specified IP address.
  returned: success

subnet:
  description: Dict with data about the handled IP subnet.
  type: dict
  sample:
    address: 147.75.90.241
    address_family: 4
    assigned_to: {href: /devices/61f9aa5e-0530-47f5-97c2-113828e61ed0}
    cidr: 31
    created_at: '2017-08-07T15:15:30Z'
    enabled: true
    gateway: 147.75.90.240
    href: /ips/31eda960-0a16-4c0f-b196-f3dc4928529f
    id: 1eda960-0a16-4c0f-b196-f3dc4928529f
    manageable: true
    management: true
    netmask: 255.255.255.254
    network: 147.75.90.240
    public: true
  returned: success
"""


import re
import uuid

from ansible.module_utils.basic import AnsibleModule, env_fallback

HAS_PACKET_SDK = True

try:
    import packet
except ImportError:
    HAS_PACKET_SDK = False


NAME_RE = r"([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])"
HOSTNAME_RE = rf"({NAME_RE}\.)*{NAME_RE}$"
PROJECT_MAX_DEVICES = 100


PACKET_API_TOKEN_ENV_VAR = "PACKET_API_TOKEN"


ALLOWED_STATES = ["absent", "present"]


def is_valid_hostname(hostname):
    return re.match(HOSTNAME_RE, hostname) is not None


def is_valid_uuid(myuuid):
    try:
        val = uuid.UUID(myuuid, version=4)
    except ValueError:
        return False
    return str(val) == myuuid


def get_existing_devices(module, packet_conn):
    project_id = module.params.get("project_id")
    if not is_valid_uuid(project_id):
        raise Exception(f"Project ID {project_id} does not seem to be valid")

    per_page = module.params.get("device_count")
    return packet_conn.list_devices(project_id, params={"per_page": per_page})


def get_specified_device_identifiers(module):
    if module.params.get("device_id"):
        _d_id = module.params.get("device_id")
        if not is_valid_uuid(_d_id):
            raise Exception(f"Device ID '{_d_id}' does not seem to be valid")
        return {"device_id": _d_id, "hostname": None}
    elif module.params.get("hostname"):
        _hn = module.params.get("hostname")
        if not is_valid_hostname(_hn):
            raise Exception(f"Hostname '{_hn}' does not seem to be valid")
        return {"hostname": _hn, "device_id": None}
    else:
        return {"hostname": None, "device_id": None}


def parse_subnet_cidr(cidr):
    if "/" not in cidr:
        raise Exception("CIDR expression in wrong format, must be address/prefix_len")
    addr, prefixlen = cidr.split("/")
    try:
        prefixlen = int(prefixlen)
    except ValueError as e:
        raise Exception(f"Wrong prefix length in CIDR expression {cidr}") from e
    return addr, prefixlen


def act_on_assignment(target_state, module, packet_conn):
    return_dict = {"changed": False}
    specified_cidr = module.params.get("cidr")
    address, prefixlen = parse_subnet_cidr(specified_cidr)

    specified_identifier = get_specified_device_identifiers(module)

    if module.check_mode:
        return return_dict

    if (specified_identifier["hostname"] is None) and (specified_identifier["device_id"] is None):
        if target_state == "absent":
            # The special case to release the IP from any assignment
            for d in get_existing_devices(module, packet_conn):
                for ia in d.ip_addresses:
                    if address == ia["address"] and prefixlen == ia["cidr"]:
                        packet_conn.call_api(ia["href"], "DELETE")
                        return_dict["changed"] = True
                        return_dict["subnet"] = ia
                        return_dict["device_id"] = d.id
                        return return_dict
        raise Exception("If you assign an address, you must specify either target device ID or target unique hostname.")

    if specified_identifier["device_id"] is not None:
        device = packet_conn.get_device(specified_identifier["device_id"])
    else:
        all_devices = get_existing_devices(module, packet_conn)
        hn = specified_identifier["hostname"]
        matching_devices = [d for d in all_devices if d.hostname == hn]
        if len(matching_devices) > 1:
            raise Exception(f"There are more than one devices matching given hostname {hn}")
        if len(matching_devices) == 0:
            raise Exception(f"There is no device matching given hostname {hn}")
        device = matching_devices[0]

    return_dict["device_id"] = device.id
    assignment_dicts = [i for i in device.ip_addresses if i["address"] == address and i["cidr"] == prefixlen]
    if len(assignment_dicts) > 1:
        raise Exception(f"IP address {specified_cidr} is assigned more than once for device {device.hostname}")

    if target_state == "absent":
        if len(assignment_dicts) == 1:
            packet_conn.call_api(assignment_dicts[0]["href"], "DELETE")
            return_dict["subnet"] = assignment_dicts[0]
            return_dict["changed"] = True
    elif target_state == "present":
        if len(assignment_dicts) == 0:
            new_assignment = packet_conn.call_api(f"devices/{device.id}/ips", "POST", {"address": f"{specified_cidr}"})
            return_dict["changed"] = True
            return_dict["subnet"] = new_assignment
    return return_dict


def main():
    module = AnsibleModule(
        argument_spec=dict(
            auth_token=dict(type="str", fallback=(env_fallback, [PACKET_API_TOKEN_ENV_VAR]), no_log=True),
            device_id=dict(type="str"),
            hostname=dict(type="str"),
            project_id=dict(type="str"),
            device_count=dict(type="int", default=PROJECT_MAX_DEVICES),
            cidr=dict(type="str", required=True, aliases=["name"]),
            state=dict(choices=ALLOWED_STATES, default="present"),
        ),
        supports_check_mode=True,
        mutually_exclusive=[("hostname", "device_id")],
        required_one_of=[["hostname", "device_id", "project_id"]],
        required_by=dict(
            hostname=("project_id",),
        ),
    )

    if not HAS_PACKET_SDK:
        module.fail_json(msg="packet required for this module")

    if not module.params.get("auth_token"):
        _fail_msg = f"if Packet API token is not in environment variable {PACKET_API_TOKEN_ENV_VAR}, the auth_token parameter is required"
        module.fail_json(msg=_fail_msg)

    auth_token = module.params.get("auth_token")

    packet_conn = packet.Manager(auth_token=auth_token)

    state = module.params.get("state")

    try:
        module.exit_json(**act_on_assignment(state, module, packet_conn))
    except Exception as e:
        module.fail_json(msg=f"failed to set IP subnet to state {state}, error: {e}")


if __name__ == "__main__":
    main()
