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

# (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
#
# This file is part of Ansible
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.

DOCUMENTATION = '''
---
module: cloudflare_dns
author: "Michael Gruener (@mgruener)"
requirements:
   - "python >= 2.6"
version_added: "2.1"
short_description: manage Cloudflare DNS records
description:
   - "Manages dns records via the Cloudflare API, see the docs: U(https://api.cloudflare.com/)"
options:
  account_api_token:
    description:
      - "Account API token. You can obtain your API key from the bottom of the Cloudflare 'My Account' page, found here: U(https://www.cloudflare.com/a/account)"
    required: true
  account_email:
    description:
      - "Account email."
    required: true
  port:
    description: Service port. Required for C(type=SRV)
    required: false
    default: null
  priority:
    description: Record priority. Required for C(type=MX) and C(type=SRV)
    required: false
    default: "1"
  proto:
    description: Service protocol. Required for C(type=SRV)
    required: false
    choices: [ 'tcp', 'udp' ]
    default: null
  record:
    description:
      - Record to add. Required if C(state=present). Default is C(@) (e.g. the zone name)
    required: false
    default: "@"
    aliases: [ "name" ]
  service:
    description: Record service. Required for C(type=SRV)
    required: false
    default: null
  solo:
    description:
      - Whether the record should be the only one for that record type and record name. Only use with C(state=present)
      - This will delete all other records with the same record name and type.
    required: false
    default: null
  state:
    description:
      - Whether the record(s) should exist or not
    required: false
    choices: [ 'present', 'absent' ]
    default: present
  timeout:
    description:
      - Timeout for Cloudflare API calls
    required: false
    default: 30
  ttl:
    description:
      - The TTL to give the new record. Must be between 120 and 2,147,483,647 seconds, or 1 for automatic.
    required: false
    default: 1 (automatic)
  type:
    description:
      - The type of DNS record to create. Required if C(state=present)
    required: false
    choices: [ 'A', 'AAAA', 'CNAME', 'TXT', 'SRV', 'MX', 'NS', 'SPF' ]
    default: null
  value:
    description:
      - The record value. Required for C(state=present)
    required: false
    default: null
    aliases: [ "content" ]
  weight:
    description: Service weight. Required for C(type=SRV)
    required: false
    default: "1"
  zone:
    description:
      - The name of the Zone to work with (e.g. "example.com"). The Zone must already exist.
    required: true
    aliases: ["domain"]
'''

EXAMPLES = '''
# create a test.my.com A record to point to 127.0.0.1
- cloudflare_dns:
    zone: my.com
    record: test
    type: A
    value: 127.0.0.1
    account_email: test@example.com
    account_api_token: dummyapitoken
  register: record

# create a my.com CNAME record to example.com
- cloudflare_dns:
    zone: my.com
    type: CNAME
    value: example.com
    state: present
    account_email: test@example.com
    account_api_token: dummyapitoken

# change it's ttl
- cloudflare_dns:
    zone: my.com
    type: CNAME
    value: example.com
    ttl: 600
    state: present
    account_email: test@example.com
    account_api_token: dummyapitoken

# and delete the record
- cloudflare_dns:
    zone: my.com
    type: CNAME
    value: example.com
    state: absent
    account_email: test@example.com
    account_api_token: dummyapitoken

# create TXT record "test.my.com" with value "unique value"
# delete all other TXT records named "test.my.com"
- cloudflare_dns:
    domain: my.com
    record: test
    type: TXT
    value: unique value
    state: present
    solo: true
    account_email: test@example.com
    account_api_token: dummyapitoken

# create a SRV record _foo._tcp.my.com
- cloudflare_dns:
    domain: my.com
    service: foo
    proto: tcp
    port: 3500
    priority: 10
    weight: 20
    type: SRV
    value: fooserver.my.com
'''

RETURN = '''
record:
    description: dictionary containing the record data
    returned: success, except on record deletion
    type: dictionary
    contains:
        content:
            description: the record content (details depend on record type)
            returned: success
            type: string
            sample: 192.0.2.91
        created_on:
            description: the record creation date
            returned: success
            type: string
            sample: 2016-03-25T19:09:42.516553Z
        data:
            description: additional record data
            returned: success, if type is SRV
            type: dictionary
            sample: {
                name: "jabber",
                port: 8080,
                priority: 10,
                proto: "_tcp",
                service: "_xmpp",
                target: "jabberhost.sample.com",
                weight: 5,
            }
        id:
            description: the record id
            returned: success
            type: string
            sample: f9efb0549e96abcb750de63b38c9576e
        locked:
            description: No documentation available
            returned: success
            type: boolean
            sample: False
        meta:
            description: No documentation available
            returned: success
            type: dictionary
            sample: { auto_added: false }
        modified_on:
            description: record modification date
            returned: success
            type: string
            sample: 2016-03-25T19:09:42.516553Z
        name:
            description: the record name as FQDN (including _service and _proto for SRV)
            returned: success
            type: string
            sample: www.sample.com
        priority:
            description: priority of the MX record
            returned: success, if type is MX
            type: int
            sample: 10
        proxiable:
            description: whether this record can be proxied through cloudflare
            returned: success
            type: boolean
            sample: False
        proxied:
            description: whether the record is proxied through cloudflare
            returned: success
            type: boolean
            sample: False
        ttl:
            description: the time-to-live for the record
            returned: success
            type: int
            sample: 300
        type:
            description: the record type
            returned: success
            type: string
            sample: A
        zone_id:
            description: the id of the zone containing the record
            returned: success
            type: string
            sample: abcede0bf9f0066f94029d2e6b73856a
        zone_name:
            description: the name of the zone containing the record
            returned: success
            type: string
            sample: sample.com
'''

try:
    import json
except ImportError:
    try:
        import simplejson as json
    except ImportError:
        # Let snippet from module_utils/basic.py return a proper error in this case
        pass

import urllib

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


class CloudflareAPI(object):

    cf_api_endpoint = 'https://api.cloudflare.com/client/v4'
    changed = False

    def __init__(self, module):
        self.module            = module
        self.account_api_token = module.params['account_api_token']
        self.account_email     = module.params['account_email']
        self.port              = module.params['port']
        self.priority          = module.params['priority']
        self.proto             = module.params['proto']
        self.record            = module.params['record']
        self.service           = module.params['service']
        self.is_solo           = module.params['solo']
        self.state             = module.params['state']
        self.timeout           = module.params['timeout']
        self.ttl               = module.params['ttl']
        self.type              = module.params['type']
        self.value             = module.params['value']
        self.weight            = module.params['weight']
        self.zone              = module.params['zone']

        if self.record == '@':
            self.record = self.zone

        if (self.type in ['CNAME','NS','MX','SRV']) and (self.value is not None):
            self.value = self.value.rstrip('.')

        if (self.type == 'SRV'):
            if (self.proto is not None) and (not self.proto.startswith('_')):
                self.proto = '_' + self.proto
            if (self.service is not None) and (not self.service.startswith('_')):
                self.service = '_' + self.service

        if not self.record.endswith(self.zone):
            self.record = self.record + '.' + self.zone

    def _cf_simple_api_call(self,api_call,method='GET',payload=None):
        headers = { 'X-Auth-Email': self.account_email,
                    'X-Auth-Key': self.account_api_token,
                    'Content-Type': 'application/json' }
        data = None
        if payload:
            try:
                data = json.dumps(payload)
            except Exception:
                e = get_exception()
                self.module.fail_json(msg="Failed to encode payload as JSON: %s " % str(e))

        resp, info = fetch_url(self.module,
                               self.cf_api_endpoint + api_call,
                               headers=headers,
                               data=data,
                               method=method,
                               timeout=self.timeout)

        if info['status'] not in [200,304,400,401,403,429,405,415]:
            self.module.fail_json(msg="Failed API call {0}; got unexpected HTTP code {1}".format(api_call,info['status']))

        error_msg = ''
        if info['status'] == 401:
            # Unauthorized
            error_msg = "API user does not have permission; Status: {0}; Method: {1}: Call: {2}".format(info['status'],method,api_call)
        elif info['status'] == 403:
            # Forbidden
            error_msg = "API request not authenticated; Status: {0}; Method: {1}: Call: {2}".format(info['status'],method,api_call)
        elif info['status'] == 429:
            # Too many requests
            error_msg = "API client is rate limited; Status: {0}; Method: {1}: Call: {2}".format(info['status'],method,api_call)
        elif info['status'] == 405:
            # Method not allowed
            error_msg = "API incorrect HTTP method provided; Status: {0}; Method: {1}: Call: {2}".format(info['status'],method,api_call)
        elif info['status'] == 415:
            # Unsupported Media Type
            error_msg = "API request is not valid JSON; Status: {0}; Method: {1}: Call: {2}".format(info['status'],method,api_call)
        elif info ['status'] == 400:
            # Bad Request
            error_msg = "API bad request; Status: {0}; Method: {1}: Call: {2}".format(info['status'],method,api_call)

        result = None
        try:
            content = resp.read()
        except AttributeError:
            if info['body']:
                content = info['body']
            else:
                error_msg += "; The API response was empty"

        if content:
            try:
                result = json.loads(content)
            except json.JSONDecodeError:
                error_msg += "; Failed to parse API response: {0}".format(content)

        # received an error status but no data with details on what failed
        if  (info['status'] not in [200,304]) and (result is None):
            self.module.fail_json(msg=error_msg)

        if not result['success']:
            error_msg += "; Error details: "
            for error in result['errors']:
                error_msg += "code: {0}, error: {1}; ".format(error['code'],error['message'])
                if 'error_chain' in error:
                    for chain_error in error['error_chain']:
                        error_msg += "code: {0}, error: {1}; ".format(chain_error['code'],chain_error['message'])
            self.module.fail_json(msg=error_msg)

        return result, info['status']

    def _cf_api_call(self,api_call,method='GET',payload=None):
        result, status = self._cf_simple_api_call(api_call,method,payload)

        data = result['result']

        if 'result_info' in result:
            pagination = result['result_info']
            if pagination['total_pages'] > 1:
                next_page = int(pagination['page']) + 1
                parameters = ['page={0}'.format(next_page)]
                # strip "page" parameter from call parameters (if there are any)
                if '?' in api_call:
                    raw_api_call,query = api_call.split('?',1)
                    parameters += [param for param in query.split('&') if not param.startswith('page')]
                else:
                    raw_api_call = api_call
                while next_page <= pagination['total_pages']:
                    raw_api_call += '?' + '&'.join(parameters)
                    result, status = self._cf_simple_api_call(raw_api_call,method,payload)
                    data += result['result']
                    next_page += 1

        return data, status

    def _get_zone_id(self,zone=None):
        if not zone:
            zone = self.zone

        zones = self.get_zones(zone)
        if len(zones) > 1:
            self.module.fail_json(msg="More than one zone matches {0}".format(zone))

        if len(zones) < 1:
            self.module.fail_json(msg="No zone found with name {0}".format(zone))

        return zones[0]['id']

    def get_zones(self,name=None):
        if not name:
            name = self.zone
        param = ''
        if name:
            param = '?' + urllib.urlencode({'name' : name})
        zones,status = self._cf_api_call('/zones' + param)
        return zones

    def get_dns_records(self,zone_name=None,type=None,record=None,value=''):
        if not zone_name:
            zone_name = self.zone
        if not type:
            type = self.type
        if not record:
            record = self.record
        # necessary because None as value means to override user
        # set module value
        if (not value) and (value is not None):
            value = self.value

        zone_id = self._get_zone_id()
        api_call = '/zones/{0}/dns_records'.format(zone_id)
        query = {}
        if type:
            query['type'] = type
        if record:
            query['name'] = record
        if value:
            query['content'] = value
        if query:
            api_call += '?' + urllib.urlencode(query)

        records,status = self._cf_api_call(api_call)
        return records

    def delete_dns_records(self,**kwargs):
        params = {}
        for param in ['port','proto','service','solo','type','record','value','weight','zone']:
            if param in kwargs:
                params[param] = kwargs[param]
            else:
                params[param] = getattr(self,param)

        records = []
        content = params['value']
        search_record = params['record']
        if params['type'] == 'SRV':
            content = str(params['weight']) + '\t' + str(params['port']) + '\t' + params['value']
            search_record = params['service'] + '.' + params['proto'] + '.' + params['record']
        if params['solo']:
            search_value = None
        else:
            search_value = content

        records = self.get_dns_records(params['zone'],params['type'],search_record,search_value)

        for rr in records:
            if params['solo']:
                if not ((rr['type'] == params['type']) and (rr['name'] == search_record) and (rr['content'] == content)):
                    self.changed = True
                    if not self.module.check_mode:
                        result, info = self._cf_api_call('/zones/{0}/dns_records/{1}'.format(rr['zone_id'],rr['id']),'DELETE')
            else:
                self.changed = True
                if not self.module.check_mode:
                    result, info = self._cf_api_call('/zones/{0}/dns_records/{1}'.format(rr['zone_id'],rr['id']),'DELETE')
        return self.changed

    def ensure_dns_record(self,**kwargs):
        params = {}
        for param in ['port','priority','proto','service','ttl','type','record','value','weight','zone']:
          if param in kwargs:
              params[param] = kwargs[param]
          else:
              params[param] = getattr(self,param)

        search_value = params['value']
        search_record = params['record']
        new_record = None
        if (params['type'] is None) or (params['record'] is None):
            self.module.fail_json(msg="You must provide a type and a record to create a new record")

        if (params['type'] in [ 'A','AAAA','CNAME','TXT','MX','NS','SPF']):
            if not params['value']:
                self.module.fail_json(msg="You must provide a non-empty value to create this record type")

            # there can only be one CNAME per record
            # ignoring the value when searching for existing
            # CNAME records allows us to update the value if it
            # changes
            if params['type'] == 'CNAME':
                search_value = None

            new_record = {
                "type": params['type'],
                "name": params['record'],
                "content": params['value'],
                "ttl": params['ttl']
            }

        if params['type'] == 'MX':
            for attr in [params['priority'],params['value']]:
                if (attr is None) or (attr == ''):
                    self.module.fail_json(msg="You must provide priority and a value to create this record type")
            new_record = {
                "type": params['type'],
                "name": params['record'],
                "content": params['value'],
                "priority": params['priority'],
                "ttl": params['ttl']
            }

        if params['type'] == 'SRV':
            for attr in [params['port'],params['priority'],params['proto'],params['service'],params['weight'],params['value']]:
                if (attr is None) or (attr == ''):
                    self.module.fail_json(msg="You must provide port, priority, proto, service, weight and a value to create this record type")
            srv_data = {
                "target": params['value'],
                "port": params['port'],
                "weight": params['weight'],
                "priority": params['priority'],
                "name": params['record'][:-len('.' + params['zone'])],
                "proto": params['proto'],
                "service": params['service']
            }
            new_record = { "type": params['type'], "ttl": params['ttl'], 'data': srv_data }
            search_value = str(params['weight']) + '\t' + str(params['port']) + '\t' + params['value']
            search_record = params['service'] + '.' + params['proto'] + '.' + params['record']

        zone_id = self._get_zone_id(params['zone'])
        records = self.get_dns_records(params['zone'],params['type'],search_record,search_value)
        # in theory this should be impossible as cloudflare does not allow
        # the creation of duplicate records but lets cover it anyways
        if len(records) > 1:
            self.module.fail_json(msg="More than one record already exists for the given attributes. That should be impossible, please open an issue!")
        # record already exists, check if it must be updated
        if len(records) == 1:
            cur_record = records[0]
            do_update = False
            if (params['ttl'] is not None) and (cur_record['ttl'] != params['ttl'] ):
                do_update = True
            if (params['priority'] is not None) and ('priority' in cur_record) and (cur_record['priority'] != params['priority']):
                do_update = True
            if ('data' in new_record) and ('data' in cur_record):
                if (cur_record['data'] > new_record['data']) - (cur_record['data'] < new_record['data']):
                    do_update = True
            if (type == 'CNAME') and (cur_record['content'] != new_record['content']):
                do_update = True
            if do_update:
                if not self.module.check_mode:
                    result, info = self._cf_api_call('/zones/{0}/dns_records/{1}'.format(zone_id,records[0]['id']),'PUT',new_record)
                self.changed = True
                return result,self.changed
            else:
                return records,self.changed
        if not self.module.check_mode:
            result, info = self._cf_api_call('/zones/{0}/dns_records'.format(zone_id),'POST',new_record)
        self.changed = True
        return result,self.changed

def main():
    module = AnsibleModule(
        argument_spec = dict(
            account_api_token = dict(required=True, no_log=True, type='str'),
            account_email     = dict(required=True, type='str'),
            port              = dict(required=False, default=None, type='int'),
            priority          = dict(required=False, default=1, type='int'),
            proto             = dict(required=False, default=None, choices=[ 'tcp', 'udp' ], type='str'),
            record            = dict(required=False, default='@', aliases=['name'], type='str'),
            service           = dict(required=False, default=None, type='str'),
            solo              = dict(required=False, default=None, type='bool'),
            state             = dict(required=False, default='present', choices=['present', 'absent'], type='str'),
            timeout           = dict(required=False, default=30, type='int'),
            ttl               = dict(required=False, default=1, type='int'),
            type              = dict(required=False, default=None, choices=[ 'A', 'AAAA', 'CNAME', 'TXT', 'SRV', 'MX', 'NS', 'SPF' ], type='str'),
            value             = dict(required=False, default=None, aliases=['content'], type='str'),
            weight            = dict(required=False, default=1, type='int'),
            zone              = dict(required=True, default=None, aliases=['domain'], type='str'),
        ),
        supports_check_mode = True,
        required_if = ([
                ('state','present',['record','type']),
                ('type','MX',['priority','value']),
                ('type','SRV',['port','priority','proto','service','value','weight']),
                ('type','A',['value']),
                ('type','AAAA',['value']),
                ('type','CNAME',['value']),
                ('type','TXT',['value']),
                ('type','NS',['value']),
                ('type','SPF',['value'])
            ]
       ),
       required_one_of = (
            [['record','value','type']]
        )
    )

    changed = False
    cf_api = CloudflareAPI(module)

    # sanity checks
    if cf_api.is_solo and cf_api.state == 'absent':
        module.fail_json(msg="solo=true can only be used with state=present")

    # perform add, delete or update (only the TTL can be updated) of one or
    # more records
    if cf_api.state == 'present':
        # delete all records matching record name + type
        if cf_api.is_solo:
            changed = cf_api.delete_dns_records(solo=cf_api.is_solo)
        result,changed = cf_api.ensure_dns_record()
        if isinstance(result,list):
            module.exit_json(changed=changed,result={'record': result[0]})
        else:
            module.exit_json(changed=changed,result={'record': result})
    else:
        # force solo to False, just to be sure
        changed = cf_api.delete_dns_records(solo=False)
        module.exit_json(changed=changed)


if __name__ == '__main__':
    main()
