#!/usr/bin/env python
# -*- coding: utf-8 -*-
# 2017-05-02 Friedrich Weber <friedrich.weber@netknights.it>
#            Improve token matching
# 2017-04-25 Cornelius Kölbel <cornelius.koelbel@netknights.it>
#
# Copyright (c) 2017, Cornelius Kölbel
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from dateutil import parser
from dateutil.tz import tzlocal, tzutc
from flask.ext.script.commands import InvalidCommand

from privacyidea.lib.policy import ACTION
from privacyidea.lib.utils import parse_legacy_time

__doc__ = """
This script can be used to clean up the token database.

It can list, disable, delete or mark tokens based on

Conditions:

* last_auth
* orphaned tokens
* any tokeninfo
* unassigned token / token has no user
* tokentype

Actions:

* list
* unassign
* mark
* disable
* delete


    privacyidea-token-janitor find 
        --last_auth=10h|7d|2y
        --tokeninfo-key=<key>
        --tokeninfo-value=<value>
        --tokeninfo-value-greater-than=<value>
        --tokeninfo-value-less-than=<value>
        --tokeninfo-value-after=<value>
        --tokeninfo-value-before=<value>
        --unassigned
        --orphaned
        --tokentype=<type>
        --serial=<regexp>
        --description=<regexp>
        
        --action=delete|unassign|disable|mark
        
        --set-description="new description"
        --set-tokeninfo-key=<key>
        --set-tokeninfo-value=<value>    
    

"""
from privacyidea.lib.token import (get_tokens, remove_token, enable_token,
                                   unassign_token)
from privacyidea.app import create_app
from flask.ext.script import Manager
import re
import sys

__version__ = "0.1"

app = create_app(config_name='production', silent=True)
manager = Manager(app)

def _try_convert_to_integer(given_value_string):
    try:
        return int(given_value_string)
    except ValueError:
        raise InvalidCommand('Not an integer: {}'.format(given_value_string))


def _compare_greater_than(key, given_value_string):
    """
    :return: a function which returns True if its parameter (converted to an integer) is greater than
    *given_value_string* (converted to an integer).
    """
    given_value = _try_convert_to_integer(given_value_string)

    def comparator(value):
        try:
            return int(value) > given_value
        except ValueError:
            return False
    return comparator


def _compare_less_than(key, given_value_string):
    """
    :return: a function which returns True if its parameter (converted to an integer) is less than
    *given_value_string* (converted to an integer).
    """
    given_value = _try_convert_to_integer(given_value_string)

    def comparator(value):
        try:
            return int(value) < given_value
        except ValueError:
            return False
    return comparator

def _parse_datetime(key, value):
    if key == ACTION.LASTAUTH:
        # Special case for last_auth: Legacy values are given in UTC time!
        last_auth = parser.parse(value)
        if not last_auth.tzinfo:
            last_auth = parser.parse(value, tzinfos=tzutc)
        return last_auth
    else:
        # Other values are given in local time
        return parser.parse(parse_legacy_time(value))


def _try_convert_to_datetime(given_value_string):
    try:
        parsed = parser.parse(given_value_string, dayfirst=True)
        if not parsed.tzinfo:
            parsed = parser.parse(given_value_string, dayfirst=True, tzinfos=tzlocal)
        return parsed
    except ValueError:
        raise InvalidCommand('Not a valid datetime format: {}'.format(given_value_string))


def _compare_after(key, given_value_string):
    """
    :return: a function which returns True if its parameter (converted to a datetime) occurs after
    *given_value_string* (converted to a datetime).
    """
    given_value = _try_convert_to_datetime(given_value_string)

    def comparator(value):
        try:
            return _parse_datetime(key, value) > given_value
        except ValueError:
            return False
    return comparator


def _compare_before(key, given_value_string):
    """
    :return: a function which returns True if its parameter (converted to a datetime) occurs before
    *given_value_string* (converted to a datetime).
    """
    given_value = _try_convert_to_datetime(given_value_string)

    def comparator(value):
        try:
            return _parse_datetime(key, value) < given_value
        except ValueError:
            return False
    return comparator


def _compare_regex(key, given_regex):
    def comparator(value):
        return re.search(given_regex, value)
    return comparator


def build_tokenvalue_filter(key,
                            tokeninfo_value,
                            tokeninfo_value_greater_than,
                            tokeninfo_value_less_than,
                            tokeninfo_value_after,
                            tokeninfo_value_before):
    """
    Build and return a token value filter, which is a list of comparator functions. Each comparator function
    takes a tokeninfo value and returns True if the user-defined criterion matches.
    The filter matches a record if *all* comparator functions return True, i.e. if the conjunction of
    all comparators returns True.
    :param key: user-supplied --tokeninfo-key= value
    :param tokeninfo_value: user-supplied --tokeninfo-value= value
    :param tokeninfo_value_greater_than: user-supplied --tokeninfo-value-greater-than= value
    :param tokeninfo_value_less_than: user-supplied --tokeninfo-value-less-than= value
    :param tokeninfo_value_after: user-supplied --tokeninfo-value-after= value
    :param tokeninfo_value_before: user-supplied --tokeninfo-value-before= value
    :return: list of functions
    """
    filter = []
    if tokeninfo_value:
        filter.append(_compare_regex(key, tokeninfo_value))
    if tokeninfo_value_greater_than:
        filter.append(_compare_greater_than(key, tokeninfo_value_greater_than))
    if tokeninfo_value_less_than:
        filter.append(_compare_less_than(key, tokeninfo_value_less_than))
    if tokeninfo_value_after:
        filter.append(_compare_after(key, tokeninfo_value_after))
    if tokeninfo_value_before:
        filter.append(_compare_before(key, tokeninfo_value_before))
    return filter


def _get_tokenlist(last_auth, assigned, active, tokeninfo_key,
                   tokeninfo_value_filter, orphaned, tokentype, serial, description):
    tlist = []
    filter_tokentype = None
    filter_active = None
    filter_assigned = None

    if assigned is not None:
        filter_assigned = assigned.lower() == "true"
    if active is not None:
        filter_active = active.lower() == "true"

    tokenobj_list = get_tokens(tokentype=tokentype,
                               active=filter_active,
                               assigned=filter_assigned)
    for token_obj in tokenobj_list:
        if last_auth and token_obj.check_last_auth_newer(last_auth):
            continue
        if serial and not re.search(serial, token_obj.token.serial):
            continue
        if description and not re.search(description,
                                         token_obj.token.description):
            continue
        if tokeninfo_value_filter and tokeninfo_key:
            value = token_obj.get_tokeninfo(tokeninfo_key)
            # if the tokeninfo key is not even set, it does not match the filter
            if value is None:
                continue
            # suppose not all comparator functions return True
            # => at least one comparator function returns False
            # => at least one user-supplied criterion does not match
            # => the token object does not match the user-supplied criteria
            if not all(comparator(value) for comparator in tokeninfo_value_filter):
                continue
        if orphaned and not token_obj.is_orphaned():
            continue

        # if everything matched, we append the token object
        tlist.append(token_obj)

    return tlist


@manager.option('--last_auth', help='Can be something like 10h, 7d, or 2y')
@manager.option('--assigned', help='True|False|None')
@manager.option('--active', help='True|False|None')
@manager.option('--tokeninfo-value', metavar='REGEX', help='The tokeninfo value to match')
@manager.option('--tokeninfo-value-greater-than', metavar='INTEGER',
                help='Interpret tokeninfo values as integers and match only if they are greater than the given integer')
@manager.option('--tokeninfo-value-less-than', metavar='INTEGER',
                help='Interpret tokeninfo values as integers and match only if they are smaller than the given integer')
@manager.option('--tokeninfo-value-after', metavar='DATETIME',
                help='Interpret tokeninfo values as datetimes, match only if they occur after the given '
                     'ISO 8601 datetime')
@manager.option('--tokeninfo-value-before', metavar='DATETIME',
                help='Interpret tokeninfo values as datetimes, match only if they occur before the given '
                     ' ISO 8601 datetime')
@manager.option('--tokeninfo-key', help='The tokeninfo key to match')
@manager.option('--orphaned',
                help='Whether the token is an orphaned token. Set to 1')
@manager.option('--tokentype', help='The tokentype to search.')
@manager.option('--serial', help='A regular expression on the serial')
@manager.option('--description', help='A regular expression on the description')
@manager.option('--action', help='Which action should be performed on the '
                                 'found tokens')
@manager.option('--set-description', help='')
@manager.option('--set-tokeninfo-key', help='')
@manager.option('--set-tokeninfo-value', help='')
def find(last_auth, assigned, active, tokeninfo_key, tokeninfo_value,
         tokeninfo_value_greater_than, tokeninfo_value_less_than,
         tokeninfo_value_after, tokeninfo_value_before,
         orphaned, tokentype, serial, description, action, set_description,
         set_tokeninfo_key, set_tokeninfo_value):
    """
    finds all tokens which match the conditions
    """
    if action and action not in ["disable", "delete", "unassign", "mark"]:
            sys.stderr.write("Unknown action. Allowed actions are 'disable', "
                        "'delete', 'unassign', 'mark'\n")
            sys.exit(1)
    filter = build_tokenvalue_filter(tokeninfo_key,
                                     tokeninfo_value,
                                     tokeninfo_value_greater_than,
                                     tokeninfo_value_less_than,
                                     tokeninfo_value_after,
                                     tokeninfo_value_before)

    tlist = _get_tokenlist(last_auth, assigned, active, tokeninfo_key,
                           filter, orphaned, tokentype, serial,
                           description)

    if not action:
        print("Token serial\tTokeninfo")
        print("="*42)
        for token_obj in tlist:
            print("{0!s} ({1!s})\n\t\t{2!s}\n\t\t{3!s}".format(
                token_obj.token.serial,
                token_obj.token.tokentype,
                token_obj.token.description,
                token_obj.get_tokeninfo()))

    else:
        for token_obj in tlist:
            try:
                if action == "disable":
                    enable_token(serial=token_obj.token.serial, enable=False)
                    print("Disabling token {0!s}".format(token_obj.token.serial))
                elif action == "delete":
                    remove_token(serial=token_obj.token.serial)
                    print("Deleting token {0!s}".format(token_obj.token.serial))
                elif action == "unassign":
                    unassign_token(serial=token_obj.token.serial)
                    print("Unassigning token {0!s}".format(token_obj.token.serial))
                elif action == "mark":
                    if set_description:
                        print("Setting description for token {0!s}: {1!s}".format(
                            token_obj.token.serial, set_description))
                        token_obj.set_description(set_description)
                        token_obj.save()
                    if set_tokeninfo_value and set_tokeninfo_key:
                        print("Setting tokeninfo for token {0!s}: {1!s}={2!s}".format(
                            token_obj.token.serial, set_tokeninfo_key, set_tokeninfo_value))
                        token_obj.add_tokeninfo(set_tokeninfo_key, set_tokeninfo_value)
                        token_obj.save()
            except Exception as exx:
                print("Failed to process token {0}.".format(
                    token_obj.token.serial))
                print(u"{0}".format(exx))


if __name__ == '__main__':
    manager.run()

