#!/usr/bin/python

# Copyright Ansible Project
# 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: dnsmadeeasy
short_description: Interface with dnsmadeeasy.com (a DNS hosting service)
description:
  - 'Manages DNS records using the v2 REST API of the DNS Made Easy service. It handles records only; there is no manipulation
    of domains or monitor/account support yet. See: U(https://www.dnsmadeeasy.com/integration/restapi/).'
extends_documentation_fragment:
  - community.general._attributes
attributes:
  check_mode:
    support: none
  diff_mode:
    support: none
options:
  account_key:
    description:
      - Account API Key.
    required: true
    type: str

  account_secret:
    description:
      - Account Secret Key.
    required: true
    type: str

  domain:
    description:
      - Domain to work with. Can be the domain name (for example V(mydomain.com)) or the numeric ID of the domain in DNS Made
        Easy (for example V(839989)) for faster resolution.
    required: true
    type: str

  sandbox:
    description:
      - Decides if the sandbox API should be used. Otherwise (default) the production API of DNS Made Easy is used.
    type: bool
    default: false

  record_name:
    description:
      - Record name to get/create/delete/update. If O(record_name) is not specified; all records for the domain are returned
        in "result" regardless of the state argument.
    type: str

  record_type:
    description:
      - Record type.
    choices: ['A', 'AAAA', 'CNAME', 'ANAME', 'HTTPRED', 'MX', 'NS', 'PTR', 'SRV', 'TXT']
    type: str

  record_value:
    description:
      - 'Record value. HTTPRED: <redirection URL>, MX: <priority> <target name>, NS: <name server>, PTR: <target name>, SRV:
        <priority> <weight> <port> <target name>, TXT: <text value>".'
      - If O(record_value) is not specified; no changes are made and the record is returned in RV(ignore:result) (in other
        words, this module can be used to fetch a record's current ID, type, and TTL).
    type: str

  record_ttl:
    description:
      - Record's "Time-To-Live". Number of seconds the record remains cached in DNS servers.
    default: 1800
    type: int

  state:
    description:
      - Whether the record should exist or not.
    required: true
    choices: ['present', 'absent']
    type: str

  validate_certs:
    description:
      - If V(false), SSL certificates are not validated. This should only be used on personally controlled sites using self-signed
        certificates.
    type: bool
    default: true

  monitor:
    description:
      - If V(true), add or change the monitor. This is applicable only for A records.
    type: bool
    default: false

  systemDescription:
    description:
      - Description used by the monitor.
    default: ''
    type: str

  maxEmails:
    description:
      - Number of emails sent to the contact list by the monitor.
    default: 1
    type: int

  protocol:
    description:
      - Protocol used by the monitor.
    default: 'HTTP'
    choices: ['TCP', 'UDP', 'HTTP', 'DNS', 'SMTP', 'HTTPS']
    type: str

  port:
    description:
      - Port used by the monitor.
    default: 80
    type: int

  sensitivity:
    description:
      - Number of checks the monitor performs before a failover occurs where Low = 8, Medium = 5,and High = 3.
    default: 'Medium'
    choices: ['Low', 'Medium', 'High']
    type: str

  contactList:
    description:
      - Name or ID of the contact list that the monitor notifies.
      - The default V('') means the Account Owner.
    type: str

  httpFqdn:
    description:
      - The fully qualified domain name used by the monitor.
    type: str

  httpFile:
    description:
      - The file at the Fqdn that the monitor queries for HTTP or HTTPS.
    type: str

  httpQueryString:
    description:
      - The string in the httpFile that the monitor queries for HTTP or HTTPS.
    type: str

  failover:
    description:
      - If V(true), add or change the failover. This is applicable only for A records.
    type: bool
    default: false

  autoFailover:
    description:
      - If true, fallback to the primary IP address is manual after a failover.
      - If false, fallback to the primary IP address is automatic after a failover.
    type: bool
    default: false

  ip1:
    description:
      - Primary IP address for the failover.
      - Required if adding or changing the monitor or failover.
    type: str

  ip2:
    description:
      - Secondary IP address for the failover.
      - Required if adding or changing the failover.
    type: str

  ip3:
    description:
      - Tertiary IP address for the failover.
    type: str

  ip4:
    description:
      - Quaternary IP address for the failover.
    type: str

  ip5:
    description:
      - Quinary IP address for the failover.
    type: str

notes:
  - The DNS Made Easy service requires that machines interacting with the API have the proper time and timezone set. Be sure
    you are within a few seconds of actual time by using NTP.
  - This module returns record(s) and monitor(s) in the RV(ignore:result) element when O(state=present). These values can
    be be registered and used in your playbooks.
  - Only A records can have a O(monitor) or O(failover).
  - To add failover, the O(failover), O(autoFailover), O(port), O(protocol), O(ip1), and O(ip2) options are required.
  - To add monitor, the O(monitor), O(port), O(protocol), O(maxEmails), O(systemDescription), and O(ip1) options are required.
  - The options O(monitor) and O(failover) share O(port), O(protocol), and O(ip1) options.
requirements: [hashlib, hmac]
author: "Brice Burgess (@briceburg)"
"""

EXAMPLES = r"""
- name: Fetch my.com domain records
  community.general.dnsmadeeasy:
    account_key: key
    account_secret: secret
    domain: my.com
    state: present
  register: response

- name: Create a record
  community.general.dnsmadeeasy:
    account_key: key
    account_secret: secret
    domain: my.com
    state: present
    record_name: test
    record_type: A
    record_value: 127.0.0.1

- name: Update the previously created record
  community.general.dnsmadeeasy:
    account_key: key
    account_secret: secret
    domain: my.com
    state: present
    record_name: test
    record_value: 192.0.2.23

- name: Fetch a specific record
  community.general.dnsmadeeasy:
    account_key: key
    account_secret: secret
    domain: my.com
    state: present
    record_name: test
  register: response

- name: Delete a record
  community.general.dnsmadeeasy:
    account_key: key
    account_secret: secret
    domain: my.com
    record_type: A
    state: absent
    record_name: test

- name: Add a failover
  community.general.dnsmadeeasy:
    account_key: key
    account_secret: secret
    domain: my.com
    state: present
    record_name: test
    record_type: A
    record_value: 127.0.0.1
    failover: true
    ip1: 127.0.0.2
    ip2: 127.0.0.3

- name: Add a failover
  community.general.dnsmadeeasy:
    account_key: key
    account_secret: secret
    domain: my.com
    state: present
    record_name: test
    record_type: A
    record_value: 127.0.0.1
    failover: true
    ip1: 127.0.0.2
    ip2: 127.0.0.3
    ip3: 127.0.0.4
    ip4: 127.0.0.5
    ip5: 127.0.0.6

- name: Add a monitor
  community.general.dnsmadeeasy:
    account_key: key
    account_secret: secret
    domain: my.com
    state: present
    record_name: test
    record_type: A
    record_value: 127.0.0.1
    monitor: true
    ip1: 127.0.0.2
    protocol: HTTP # default
    port: 80 # default
    maxEmails: 1
    systemDescription: Monitor Test A record
    contactList: my contact list

- name: Add a monitor with http options
  community.general.dnsmadeeasy:
    account_key: key
    account_secret: secret
    domain: my.com
    state: present
    record_name: test
    record_type: A
    record_value: 127.0.0.1
    monitor: true
    ip1: 127.0.0.2
    protocol: HTTP # default
    port: 80 # default
    maxEmails: 1
    systemDescription: Monitor Test A record
    contactList: 1174 # contact list id
    httpFqdn: http://my.com
    httpFile: example
    httpQueryString: some string

- name: Add a monitor and a failover
  community.general.dnsmadeeasy:
    account_key: key
    account_secret: secret
    domain: my.com
    state: present
    record_name: test
    record_type: A
    record_value: 127.0.0.1
    failover: true
    ip1: 127.0.0.2
    ip2: 127.0.0.3
    monitor: true
    protocol: HTTPS
    port: 443
    maxEmails: 1
    systemDescription: monitoring my.com status
    contactList: emergencycontacts

- name: Remove a failover
  community.general.dnsmadeeasy:
    account_key: key
    account_secret: secret
    domain: my.com
    state: present
    record_name: test
    record_type: A
    record_value: 127.0.0.1
    failover: false

- name: Remove a monitor
  community.general.dnsmadeeasy:
    account_key: key
    account_secret: secret
    domain: my.com
    state: present
    record_name: test
    record_type: A
    record_value: 127.0.0.1
    monitor: false
"""

# ============================================
# DNSMadeEasy module specific support methods.
#

import hashlib
import hmac
import json
import locale
from time import gmtime, strftime
from urllib.parse import urlencode

from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.urls import fetch_url


class DME2:
    def __init__(self, apikey, secret, domain, sandbox, module):
        self.module = module

        self.api = apikey
        self.secret = secret

        if sandbox:
            self.baseurl = "https://api.sandbox.dnsmadeeasy.com/V2.0/"
            self.module.warn(warning=f"Sandbox is enabled. All actions are made against the URL {self.baseurl}")
        else:
            self.baseurl = "https://api.dnsmadeeasy.com/V2.0/"

        self.domain = str(domain)
        self.domain_map = None  # ["domain_name"] => ID
        self.record_map = None  # ["record_name"] => ID
        self.records = None  # ["record_ID"] => <record>
        self.all_records = None
        self.contactList_map = None  # ["contactList_name"] => ID

        # Lookup the domain ID if passed as a domain name vs. ID
        if not self.domain.isdigit():
            self.domain = self.getDomainByName(self.domain)["id"]

        self.record_url = f"dns/managed/{self.domain}/records"
        self.monitor_url = "monitor"
        self.contactList_url = "contactList"

    def _headers(self):
        currTime = self._get_date()
        hashstring = self._create_hash(currTime)
        headers = {
            "x-dnsme-apiKey": self.api,
            "x-dnsme-hmac": hashstring,
            "x-dnsme-requestDate": currTime,
            "content-type": "application/json",
        }
        return headers

    def _get_date(self):
        locale.setlocale(locale.LC_TIME, "C")
        return strftime("%a, %d %b %Y %H:%M:%S GMT", gmtime())

    def _create_hash(self, rightnow):
        return hmac.new(self.secret.encode(), rightnow.encode(), hashlib.sha1).hexdigest()

    def query(self, resource, method, data=None):
        url = self.baseurl + resource
        if data and not isinstance(data, str):
            data = urlencode(data)

        response, info = fetch_url(self.module, url, data=data, method=method, headers=self._headers())
        if info["status"] not in (200, 201, 204):
            self.module.fail_json(msg=f"{url} returned {info['status']}, with body: {info['msg']}")

        try:
            return json.load(response)
        except Exception:
            return {}

    def getDomain(self, domain_id):
        if not self.domain_map:
            self._instMap("domain")

        return self.domains.get(domain_id, False)

    def getDomainByName(self, domain_name):
        if not self.domain_map:
            self._instMap("domain")

        return self.getDomain(self.domain_map.get(domain_name, 0))

    def getDomains(self):
        return self.query("dns/managed", "GET")["data"]

    def getRecord(self, record_id):
        if not self.record_map:
            self._instMap("record")

        return self.records.get(record_id, False)

    # Try to find a single record matching this one.
    # How we do this depends on the type of record. For instance, there
    # can be several MX records for a single record_name while there can
    # only be a single CNAME for a particular record_name. Note also that
    # there can be several records with different types for a single name.
    def getMatchingRecord(self, record_name, record_type, record_value):
        # Get all the records if not already cached
        if not self.all_records:
            self.all_records = self.getRecords()

        if record_type in ["CNAME", "ANAME", "HTTPRED", "PTR"]:
            for result in self.all_records:
                if result["name"] == record_name and result["type"] == record_type:
                    return result
            return False
        elif record_type in ["A", "AAAA", "MX", "NS", "TXT", "SRV"]:
            for result in self.all_records:
                if record_type == "MX":
                    value = record_value.split(" ")[1]
                # Note that TXT records are surrounded by quotes in the API response.
                elif record_type == "TXT":
                    value = f'"{record_value}"'
                elif record_type == "SRV":
                    value = record_value.split(" ")[3]
                else:
                    value = record_value
                if result["name"] == record_name and result["type"] == record_type and result["value"] == value:
                    return result
            return False
        else:
            raise Exception("record_type not yet supported")

    def getRecords(self):
        return self.query(self.record_url, "GET")["data"]

    def _instMap(self, type):
        # @TODO cache this call so it is executed only once per ansible execution
        map = {}
        results = {}

        # iterate over e.g. self.getDomains() || self.getRecords()
        for result in getattr(self, f"get{type.title()}s")():
            map[result["name"]] = result["id"]
            results[result["id"]] = result

        # e.g. self.domain_map || self.record_map
        setattr(self, f"{type}_map", map)
        setattr(self, f"{type}s", results)  # e.g. self.domains || self.records

    def prepareRecord(self, data):
        return json.dumps(data, separators=(",", ":"))

    def createRecord(self, data):
        # @TODO update the cache w/ resultant record + id when implemented
        return self.query(self.record_url, "POST", data)

    def updateRecord(self, record_id, data):
        # @TODO update the cache w/ resultant record + id when implemented
        return self.query(f"{self.record_url}/{record_id}", "PUT", data)

    def deleteRecord(self, record_id):
        # @TODO remove record from the cache when implemented
        return self.query(f"{self.record_url}/{record_id}", "DELETE")

    def getMonitor(self, record_id):
        return self.query(f"{self.monitor_url}/{record_id}", "GET")

    def updateMonitor(self, record_id, data):
        return self.query(f"{self.monitor_url}/{record_id}", "PUT", data)

    def prepareMonitor(self, data):
        return json.dumps(data, separators=(",", ":"))

    def getContactList(self, contact_list_id):
        if not self.contactList_map:
            self._instMap("contactList")

        return self.contactLists.get(contact_list_id, False)

    def getContactlists(self):
        return self.query(self.contactList_url, "GET")["data"]

    def getContactListByName(self, name):
        if not self.contactList_map:
            self._instMap("contactList")

        return self.getContactList(self.contactList_map.get(name, 0))


# ===========================================
# Module execution.
#


def main():
    module = AnsibleModule(
        argument_spec=dict(
            account_key=dict(required=True, no_log=True),
            account_secret=dict(required=True, no_log=True),
            domain=dict(required=True),
            sandbox=dict(default=False, type="bool"),
            state=dict(required=True, choices=["present", "absent"]),
            record_name=dict(),
            record_type=dict(choices=["A", "AAAA", "CNAME", "ANAME", "HTTPRED", "MX", "NS", "PTR", "SRV", "TXT"]),
            record_value=dict(),
            record_ttl=dict(default=1800, type="int"),
            monitor=dict(default=False, type="bool"),
            systemDescription=dict(default=""),
            maxEmails=dict(default=1, type="int"),
            protocol=dict(default="HTTP", choices=["TCP", "UDP", "HTTP", "DNS", "SMTP", "HTTPS"]),
            port=dict(default=80, type="int"),
            sensitivity=dict(default="Medium", choices=["Low", "Medium", "High"]),
            contactList=dict(),
            httpFqdn=dict(),
            httpFile=dict(),
            httpQueryString=dict(),
            failover=dict(default=False, type="bool"),
            autoFailover=dict(default=False, type="bool"),
            ip1=dict(),
            ip2=dict(),
            ip3=dict(),
            ip4=dict(),
            ip5=dict(),
            validate_certs=dict(default=True, type="bool"),
        ),
        required_together=[["record_value", "record_ttl", "record_type"]],
        required_if=[
            ["failover", True, ["autoFailover", "port", "protocol", "ip1", "ip2"]],
            ["monitor", True, ["port", "protocol", "maxEmails", "systemDescription", "ip1"]],
        ],
    )

    protocols = dict(TCP=1, UDP=2, HTTP=3, DNS=4, SMTP=5, HTTPS=6)
    sensitivities = dict(Low=8, Medium=5, High=3)

    DME = DME2(
        module.params["account_key"],
        module.params["account_secret"],
        module.params["domain"],
        module.params["sandbox"],
        module,
    )
    state = module.params["state"]
    record_name = module.params["record_name"]
    record_type = module.params["record_type"]
    record_value = module.params["record_value"]

    # Follow Keyword Controlled Behavior
    if record_name is None:
        domain_records = DME.getRecords()
        if not domain_records:
            module.fail_json(
                msg="The requested domain name is not accessible with this api_key; try using its ID if known."
            )
        module.exit_json(changed=False, result=domain_records)

    # Fetch existing record + Build new one
    current_record = DME.getMatchingRecord(record_name, record_type, record_value)
    new_record = {"name": record_name}
    for i in ["record_value", "record_type", "record_ttl"]:
        if module.params[i] is not None:
            new_record[i[len("record_") :]] = module.params[i]
    # Special handling for mx record
    if new_record["type"] == "MX":
        new_record["mxLevel"] = new_record["value"].split(" ")[0]
        new_record["value"] = new_record["value"].split(" ")[1]

    # Special handling for SRV records
    if new_record["type"] == "SRV":
        new_record["priority"] = new_record["value"].split(" ")[0]
        new_record["weight"] = new_record["value"].split(" ")[1]
        new_record["port"] = new_record["value"].split(" ")[2]
        new_record["value"] = new_record["value"].split(" ")[3]

    # Fetch existing monitor if the A record indicates it should exist and build the new monitor
    current_monitor = dict()
    new_monitor = dict()
    if current_record and current_record["type"] == "A" and current_record.get("monitor"):
        current_monitor = DME.getMonitor(current_record["id"])

    # Build the new monitor
    for i in [
        "monitor",
        "systemDescription",
        "protocol",
        "port",
        "sensitivity",
        "maxEmails",
        "contactList",
        "httpFqdn",
        "httpFile",
        "httpQueryString",
        "failover",
        "autoFailover",
        "ip1",
        "ip2",
        "ip3",
        "ip4",
        "ip5",
    ]:
        if module.params[i] is not None:
            if i == "protocol":
                # The API requires protocol to be a numeric in the range 1-6
                new_monitor["protocolId"] = protocols[module.params[i]]
            elif i == "sensitivity":
                # The API requires sensitivity to be a numeric of 8, 5, or 3
                new_monitor[i] = sensitivities[module.params[i]]
            elif i == "contactList":
                # The module accepts either the name or the id of the contact list
                contact_list_id = module.params[i]
                if not contact_list_id.isdigit() and contact_list_id != "":
                    contact_list = DME.getContactListByName(contact_list_id)
                    if not contact_list:
                        module.fail_json(msg=f"Contact list {contact_list_id} does not exist")
                    contact_list_id = contact_list.get("id", "")
                new_monitor["contactListId"] = contact_list_id
            else:
                # The module option names match the API field names
                new_monitor[i] = module.params[i]

    # Compare new record against existing one
    record_changed = False
    if current_record:
        for i in new_record:
            # Remove leading and trailing quote character from values because TXT records
            # are surrounded by quotes.
            if str(current_record[i]).strip('"') != str(new_record[i]):
                record_changed = True
        new_record["id"] = str(current_record["id"])

    monitor_changed = False
    if current_monitor:
        for i in new_monitor:
            if str(current_monitor.get(i)) != str(new_monitor[i]):
                monitor_changed = True

    # Follow Keyword Controlled Behavior
    if state == "present":
        # return the record if no value is specified
        if "value" not in new_record:
            if not current_record:
                module.fail_json(
                    msg=f"A record with name '{record_name}' does not exist for domain '{module.params['domain']}.'"
                )
            module.exit_json(changed=False, result=dict(record=current_record, monitor=current_monitor))

        # create record and monitor as the record does not exist
        if not current_record:
            record = DME.createRecord(DME.prepareRecord(new_record))
            if new_monitor.get("monitor") and record_type == "A":
                monitor = DME.updateMonitor(record["id"], DME.prepareMonitor(new_monitor))
                module.exit_json(changed=True, result=dict(record=record, monitor=monitor))
            else:
                module.exit_json(changed=True, result=dict(record=record, monitor=current_monitor))

        # update the record
        updated = False
        if record_changed:
            DME.updateRecord(current_record["id"], DME.prepareRecord(new_record))
            updated = True
        if monitor_changed:
            DME.updateMonitor(current_monitor["recordId"], DME.prepareMonitor(new_monitor))
            updated = True
        if updated:
            module.exit_json(changed=True, result=dict(record=new_record, monitor=new_monitor))

        # return the record (no changes)
        module.exit_json(changed=False, result=dict(record=current_record, monitor=current_monitor))

    elif state == "absent":
        changed = False
        # delete the record (and the monitor/failover) if it exists
        if current_record:
            DME.deleteRecord(current_record["id"])
            module.exit_json(changed=True)

        # record does not exist, return w/o change.
        module.exit_json(changed=changed)

    else:
        module.fail_json(msg=f"'{state}' is an unknown value for the state argument")


if __name__ == "__main__":
    main()
