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


DOCUMENTATION = """
---
module: mysql_query.py
author:
    - 'Elmar Athmer'
short_description: Insert/update/remove rows in a mysql table.
description:
  - This module sets values in a mysql table, or insert records. Useful for webapplications that store configurations
     in database.
options:
  table:
    required: true
    description:
      - table where to insert/update records
  identifiers:
    required: true
    description:
      - dictionary of values to identifier a row, i.e. determine if the row exists, needs to be updated etc.
  values:
    required: false
    description:
        - dictionary of values that should be 'enforced', i.e. either updated (if the row has been found by it's
          identifiers) or used during insert
  defaults:
    required: false
    description:
        - dictionary of additional values that will be used iff an insert is required.
"""

EXAMPLES = """
- name: update a row
  mysql_query.py:
    name: ansible-playbook-example
    table: simple_table
    login_host: ::1
    login_user: root
    login_password: password
    identifiers:
      identifier1: 4
      identifier2: 'eight'
    values:
      value1: 16
      value2: 'sixteen plus 1'
    defaults:
      default1: can be anything, will be ignored for update
      default2: will not change

- name: insert a row
  mysql_query.py:
    name: ansible-playbook-example
    table: simple_table
    login_host: ::1
    login_user: root
    login_password: password
    identifiers:
      identifier1: 14
      identifier2: 'eighteen'
    values:
      value1: 115
      value2: 'one-hundred-sixteen'
    defaults:
      default1: 125
      default2: one-hundred-2
"""

from contextlib import closing

from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.database import mysql_quote_identifier

# make mysql-query backward compatible with ansible < 2.7.2
try:
    from ansible.module_utils.mysql import mysql_connect, mysql_driver, mysql_driver_fail_msg
except ImportError:
    from ansible.module_utils.mysql import mysql_connect, mysqldb_found
    if mysqldb_found:
        import MySQLdb as mysql_driver
    else:
        mysql_driver = None
        mysql_driver_fail_msg = 'The MySQL-python module is required.'

from ansible.module_utils._text import to_native

DELETE_REQUIRED = 0
INSERT_REQUIRED = 1
UPDATE_REQUIRED = 2
NO_ACTION_REQUIRED = 3
ERR_NO_SUCH_TABLE = 4


def weak_equals(a, b):
    """
    helper function to compare two values where one might be a string
    :param a:
    :param b:
    :return:
    """
    # the simple case: a and b are of the same type, or of types that can be compared for equality (e.g.: int and float)
    if type(a) == type(b) or a == b:
        return a == b

    # try to stringify both and compare thenâ€¦
    return str(a) == str(b)


def tuples_weak_equals(a, b):
    """
    tests two tuples for weak equality
    :param a:
    :param b:
    :return:
    """
    if len(a) == len(b):
        pairs = zip(a, b)
        return all([weak_equals(a, b) for a, b in pairs])

    return False


def generate_where_segment(items):
    """
    generate a part of an sql statement
    :param items: key-value items representing column/values
    :return: the part to insert after "where"
    """
    return [
        "%s=%s" % (name, value) if type(value) == int
        else "%s='%s'" % (name, value)
        for (name, value) in items
    ]


def check_row_exists(cursor, table, identifiers):
    """
    check a specified row only for existence
    :param cursor: cursor object able to execute queries
    :param table: name of the table to look into
    :param identifiers:
    :return: True if the row exists, False otherwise
    """
    query = "select exists(select 1 from {table} where {values})".format(
        table=table,
        values=" AND ".join(generate_where_segment(identifiers.items())),
    )

    try:
        res = cursor.execute(query)
    except mysql_driver.ProgrammingError as e:
        (errcode, message) = e.args
        if errcode == 1146:
            return ERR_NO_SUCH_TABLE
        else:
            raise e

    exists, = cursor.fetchone()
    return exists == 1


def change_required(state, cursor, table, identifiers, desired_values):
    """
    check if a change is required
    :param state: state, either 'present' or 'absent'
    :param cursor: cursor object able to execute queries
    :param table: name of the table to look into
    :type table: str
    :param identifiers: a list of tuples (column name, value) to use in where clause
    :type identifiers: dict
    :param desired_values: a list of tuples (column name, values) that the record should match
    :type desired_values: dict
    :return: one of DELETE_REQUIRED, INSERT_REQUIRED, UPDATE_REQUIRED or NO_ACTION_REQUIRED
    :rtype: int
    """

    # first, let's determine if we do a simple existence-check, or if we also have to compare values
    if state == "absent" or not desired_values:
        row_exists = check_row_exists(cursor, table, identifiers)

        if state == "absent" and row_exists:
            return DELETE_REQUIRED

        if state == "present" and not row_exists:
            return INSERT_REQUIRED

        # other cases:  state == "absent" and not row_exists:
        #               state == "present" and row_exists
        # both require no action
        return NO_ACTION_REQUIRED

    # ok, we have to compare values, so let's make a query selecting all the columns with desired_values
    query = "select {columns} from {table} where {values}".format(
        table=table,
        columns=", ".join(desired_values.keys()),
        values=" AND ".join(generate_where_segment(identifiers.items())),
    )

    try:
        res = cursor.execute(query)
    except mysql_driver.ProgrammingError as e:
        (errcode, message) = e.args
        if errcode == 1146:
            return ERR_NO_SUCH_TABLE
        else:
            raise e

    # if no row has been found at all, an insert is required
    if res == 0:
        return INSERT_REQUIRED

    # a row has been found, so we need to compare the returned values:

    # bring the values argument into shape to compare directly to fetchone() result
    expected_query_result = tuple(desired_values.values())
    actual_result = cursor.fetchone()

    # compare expected_query_result to actual_result with implicit casting
    if tuples_weak_equals(expected_query_result, actual_result):
        return NO_ACTION_REQUIRED

    # a record has been found but does not match the desired values
    return UPDATE_REQUIRED


def execute_action(cursor, action, table, identifier, values, defaults):
    """
    when not running in check mode, this function carries out the required changes
    :param action: the actual action to execute
    :rtype: dict
    :param cursor:
    :param table:
    :param identifier:
    :param values:
    :param defaults:
    :return: a summary of the action done usable as kwargs for ansible
    """
    try:
        if action == DELETE_REQUIRED:
            return delete_record(cursor, table, identifier)
        if action == INSERT_REQUIRED:
            return insert_record(cursor, table, identifier, values, defaults)
        elif action == UPDATE_REQUIRED:
            return update_record(cursor, table, identifier, values)
        else:
            return {'failed': True, 'msg': 'Internal Error: unknown action "%s" required' % action}
    except Exception as e:
        return {'failed': True, 'msg': 'updating/inserting/deleting the record failed due to "%s".' % str(e)}


def update_record(cursor, table, identifiers, values):
    where = ' AND '.join(['{0} = %s'.format(column) for column in identifiers.keys()])
    value = ', '.join(['{0} = %s'.format(column) for column in values.keys()])

    query = "UPDATE {0} set {1} where {2} limit 1".format(table, value, where)

    cursor.execute(query, tuple(list(values.values()) + list(identifiers.values())))
    return dict(changed=True, msg='Successfully updated one row')


def insert_record(cursor, table, identifiers, values, defaults):
    row_data = dict(list(identifiers.items()) + list(values.items()) + list(defaults.items()))
    value_placeholder = ", ".join(["%s"] * len(row_data))

    query = "insert into {table} ({cols}) values ({value_placeholder})".format(
        table=table,
        cols=", ".join(row_data.keys()),
        value_placeholder=value_placeholder,
    )

    cursor.execute(query, tuple(row_data.values()))
    return dict(changed=True, msg='Successfully inserted a new row')


def delete_record(cursor, table, identifiers):
    where = ' AND '.join(['{0} = %s'.format(column) for column in identifiers.keys()])
    query = "DELETE FROM {0} WHERE {1}".format(table, where)
    cursor.execute(query, tuple(identifiers.values()))
    return dict(changed=True, msg='Successfully deleted one row')


def build_connection_parameter(params):
    """
    fetch mysql connection parameters consumable by mysqldb.connect from module args or ~/.my.cnf if necessary
    :param params:
    :return:
    :rtype: dict
    """
    # TODO: read ~/.my.cnf, set sane defaults etc.

    # map: (ansible_param_name, mysql.connect's name)
    param_name_map = [
        ('login_user', 'user'),
        ('login_password', 'passwd'),
        ('name', 'db'),
        ('login_host', 'host'),
        ('login_port', 'port'),
        ('login_unix_socket', 'unix_socket')
    ]

    t = [(mysql_name, params[ansible_name])
         for (ansible_name, mysql_name) in param_name_map
         if ansible_name in params and params[ansible_name]
         ]

    return dict(t)


def connect(connection, module):
    try:
        db_connection = mysql_driver.connect(**connection)
        return db_connection
    except Exception as e:
        module.fail_json(msg="Error connecting to mysql database: %s" % str(e))


def extract_column_value_maps(parameter):
    """
    extract mysql-quoted tuple-lists for parameters given as ansible params
    :param parameter:
    :return:
    """
    if parameter:
        for column, value in parameter.items():
            yield (mysql_quote_identifier(column, 'column'), value)


def failed(action):
    return action == ERR_NO_SUCH_TABLE


def main():
    module = AnsibleModule(
        argument_spec=dict(
            login_user=dict(default=None),
            login_password=dict(default=None, no_log=True),
            login_host=dict(default="localhost"),
            login_port=dict(default=3306, type='int'),
            login_unix_socket=dict(default=None),
            name=dict(required=True, aliases=['db']),
            state=dict(default="present", choices=['absent', 'present']),

            table=dict(required=True),

            identifiers=dict(required=True, type='dict'),
            values=dict(default=None, type='dict'),
            defaults=dict(default=None, type='dict'),

            #allow_insert=dict(default=False, type='bool'),
            #limit=dict(default=1, type='int'),
        ),
        supports_check_mode=True
    )

    if mysql_driver is None:
        module.fail_json(msg=mysql_driver_fail_msg)

    # mysql_quote all identifiers and get the parameters into shape
    table = mysql_quote_identifier(module.params['table'], 'table')
    identifiers = dict(extract_column_value_maps(module.params['identifiers']))
    values = dict(extract_column_value_maps(module.params['values']))
    defaults = dict(extract_column_value_maps(module.params['defaults']))

    exit_messages = {
        DELETE_REQUIRED: dict(changed=True, msg='Record needs to be deleted'),
        INSERT_REQUIRED: dict(changed=True, msg='No such record, need to insert'),
        UPDATE_REQUIRED: dict(changed=True, msg='Records needs to be updated'),
        NO_ACTION_REQUIRED: dict(changed=False),
        ERR_NO_SUCH_TABLE: dict(failed=True, msg='No such table %s' % table)
    }

    with closing(connect(build_connection_parameter(module.params), module)) as db_connection:
        # find out what needs to be done (independently of check-mode)
        required_action = change_required(module.params['state'], db_connection.cursor(), table, identifiers, values)

        # if we're in check mode, there's no action required, or we already failed: directly set the exit_message
        if module.check_mode or required_action == NO_ACTION_REQUIRED or failed(required_action):
            exit_message = exit_messages[required_action]
        else:
            # otherwise, execute the required action to get the exit message
            exit_message = execute_action(db_connection.cursor(), required_action, table, identifiers, values, defaults)
            db_connection.commit()

    if 'failed' in exit_message and exit_message.get('failed'):
        module.fail_json(**exit_message)
    else:
        module.exit_json(**exit_message)


if __name__ == '__main__':
    main()
