#!/usr/bin/python
"""
A script to convert Zabbix templates between versions.
"""

import xml.etree.ElementTree as ET
import argparse
import re

__version__ = '1.1.0'

def debug(msg):
    from sys import stderr
    stderr.write('%s\n' % msg)

def warn(msg):
    from sys import stderr
    stderr.write('Warning: %s\n' % msg)

class NotApplicableError(Exception):
    """
    Exception to be raised if a rule is not applicable for the desired output
    version
    """

    def __str__(self):
        return 'Rule not applicable for the desired output version'

class ConversionRule(object):
    """Base abstract class for all conversion rules"""

    def __init__(self, root, args):
        self.root = root
        self.args = args
        self.version = args.output_version

    def __str__(self):
        """
        Return a string representation describing the conversion rule.
        """

        raise NotImplementedError('__str__ method not implemented for %s' % self.__class__.__name__)

    def apply(self):
        """
        Apply this rule to self.root. Should throw NotApplicableError if this
        rule does not apply for the desired output version (self.version).
        """

        raise NotImplementedError('apply method not implemented for %s' % self.__class__.__name__)

    def versioncmp(self, version):
        """
        Compare two Zabbix version strings. Returns zero if the given version
        matches the desired output version. Returns negative if the given
        version is older and returns positive if the given version is newer.
        """

        from pkg_resources import parse_version as V
        return cmp(V(self.version), V(version))

class VersionMustMatch(ConversionRule):
    """
    Rule to ensure the /zabbix_export/version element is set to the correct
    version
    """

    outversion = 'UNKNOWN'

    def __str__(self):
        return 'Template version string must be \'%s\'' % self.outversion

    def apply(self):
        if self.versioncmp('3') < 0:
            self.outversion = '2.0'
        elif self.versioncmp('3.2') < 0:
            self.outversion = '3.0'
        elif self.versioncmp('3.3') < 0:
            self.outversion = '3.2'
        else:
            self.outversion = 'unknown'
            raise ValueError('Unsupported output version: %s' % self.version)

        self.root.find('version').text = self.outversion

class TimeStampMustBeUpdated(ConversionRule):
    """Rule to update the /zabbix_export/date timestamp"""

    def __str__(self):
        return 'Document timestamp must be updated'

    def apply(self):
        from datetime import datetime
        self.root.find('date').text = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')

class ValueMapsMustNotBeExported(ConversionRule):
    """
    Rule to remove value map definitions from templates older than 3.0.0
    """

    def __str__(self):
        return 'Value map definitions must not be exported before 3.0.0'

    def apply(self):
        if self.versioncmp('3') >= 0:
            raise NotApplicableError()

        node = self.root.find('value_maps')
        if node is not None:
            self.root.remove(node)

class FixMissingValueMaps(ConversionRule):
    """Rule to remove references to value maps"""

    def __str__(self):
        return 'Value maps cannot be used before 3.0.0 if value map squashing is enabled'

    def apply(self):
        if not self.args.squash_value_maps or self.versioncmp('3') >= 0:
            raise NotApplicableError()

        for itemtype in ('item', 'item_prototype'):
            for node in self.root.findall('.//%s/valuemap' % itemtype):
                node.clear()

class FixHttpTests(ConversionRule):
    """
    Rule to remove HTTP Test definitions from templates older that 3.2.0.
    See: ZBXNEXT-178
    """

    def __str__(self):
        return 'HTTP Tests must not be exported before 3.2.0'

    def apply(self):
        if self.versioncmp('3.2') >= 0:
            raise NotApplicableError()

        for node in self.root.findall('.//template'):
            htests = node.find('httptests')
            if htests is not None:
                node.remove(htests)

class FixDiscoveryRuleFilters(ConversionRule):
    """
    Rule to ensure discovery rule filters use a single expression before 2.3.0.
    See: ZBXNEXT-581
    """

    def __str__(self):
        return 'Discovery rule filters must use a single expression before 2.3.0'

    def apply(self):
        if self.versioncmp('2.3.0') >= 0:
            raise NotApplicableError()

        # try convert a multi-filter into a single filter string
        for discovery_rule in self.root.findall('.//discovery_rule'):
            discovery_rule_name = discovery_rule.find('name').text
            node = discovery_rule.find('filter')

            # check if multi-filter can be downgraded to filter expression
            if node.find('evaltype').text == '0' and node.find('formula').text in ('1', None):
                conditions = node.find('conditions')
                if not conditions:
                    # default blank filter
                    node.clear()

                elif len(conditions) > 1:
                    # multi-filter has too many conditions
                    warn('Filter for discovery rule \'%s\' has multiple conditions and cannot be converted - dropping' % discovery_rule_name)
                    node.clear()

                else:
                    condition = conditions[0]
                    if condition.findtext('.//operator') == '8' and condition.findtext('.//formulaid') == 'A':
                        # convert to filter expression
                        filter_str = '%s:%s' % (condition.findtext('macro'), condition.findtext('value'))
                        node.clear()
                        node.text = filter_str
                    else:
                        # unsupported operator or formula id
                        warn('filter condition for discovery rule \'%s\' uses an operator or formula ID that cannot be converted - dropping' % discovery_rule_name)
                        node.clear()
            else:
                raise ValueError('malformed discovery rule filter in \'%s\'' % discovery_rule_name)

class FixApplicationPrototypes(ConversionRule):
    """
    Rule to ensure application prototypes are not exported with discovery item
    prototypes before 2.5.0.
    See: ZBXNEXT-1219
    """

    def __str__(self):
        return 'Application prototypes cannot be used before 2.5.0'

    def apply(self):
        if self.versioncmp('2.5.0') >= 0:
            raise NotApplicableError()

        for node in self.root.findall('.//item_prototype'):
            aps = node.find('application_prototypes')
            if aps is not None:
                node.remove(aps)

class FixSNMPDiscovery(ConversionRule):
    """
    Rule to ensure the OID for SNMP discovery rules use a single OID before
    2.5.0.
    See: ZBXNEXT-1554
    """

    def __str__(self):
        return 'SNMP Discovery rules must use a single OID before 2.5.0'

    def apply(self):
        if self.versioncmp('2.5.0') >= 0:
            raise NotApplicableError()

        for discovery_rule in self.root.findall('.//discovery_rule'):
            node = discovery_rule.find('snmp_oid')
            if node is not None and node.text is not None and node.text.startswith('discovery['):
                match = re.search(r"^discovery\[(.*?,)([^,\]]+)", node.text)
                if match is not None and len(match.groups()) == 2:
                    node.text = match.group(2)
                else:
                    raise ValueError('Unrecognised SNMP OID for discovery rule \'%s\'' % discovery_rule.findtext('name'))

class FixTriggerPrototypeDependencies(ConversionRule):
    """
    Rule to ensure trigger prototype dependencies are not exported before 2.5.0.
    See: ZBXNEXT-1229
    """

    def __str__(self):
        return 'Trigger prototype dependencies cannot be used before 2.5.0'

    def apply(self):
        if self.versioncmp('2.5.0') >= 0:
            raise NotApplicableError()

        for trigger_prototype in self.root.findall('.//trigger_prototype'):
            node = trigger_prototype.find('dependencies')
            if node is not None:
                trigger_prototype.remove(node)

class FixTriggerOperators(ConversionRule):
    """
    Rule to ensure trigger expressions use old style operators before 2.4.
    The following conversions are made:
    
      * and     -> &
      * or      -> |
      * <>      -> #

    See: https://www.zabbix.com/documentation/2.2/manual/config/triggers/expression
    """

    def __str__(self):
        return 'Triggers must use old style operators before 2.4.0'

    def apply(self):
        if self.versioncmp('2.4') >= 0:
            raise NotApplicableError()

        for triggertype in ( 'trigger', 'trigger_prototype', 'dependency' ):
            for trigger in self.root.findall('.//%s' % triggertype):
                node = trigger.find('expression')
                if node is not None and node.text is not None:
                    node.text = re.sub(r"\s+and\s+", '&', node.text)
                    node.text = re.sub(r"\s+or\s+", '|', node.text)
                    node.text = re.sub(r"<>", "#", node.text)

class FixLastTriggerFunction(ConversionRule):
    """
    Rule to ensure a default parameter value is specified when calling the
    last() trigger expression before 2.2.
    """

    def __str__(self):
        return 'Trigger function \'last()\' must include a parameter'

    def apply(self):
        if self.versioncmp('2.2') >= 0:
            raise NotApplicableError()

        for node in self.root.findall('.//expression'):
            if node is not None and node.text is not None:
                node.text = re.sub(r"\.last\(\)", '.last(0)', node.text)

def __main__():
    """
    Script entry point.
    """

    # parse cmdline args
    parser = argparse.ArgumentParser(description='Migrate Zabbix templates between versions')
    parser.add_argument('-v', '--version',
                        action='version',
                        version='%%(prog)s %s' % __version__)

    parser.add_argument('-o', '--output-version',
                        help='target Zabbix version',
                        dest='output_version',
                        type=str,
                        metavar='X.Y.Z',
                        required=True)

    parser.add_argument('-s', '--squash-value-maps',
                        help='remove references to value maps for versions older than 3.0.0',
                        dest='squash_value_maps',
                        action='store_true')

    parser.add_argument('file',
                        help='Zabbix template XML file',
                        type=file)

    args = parser.parse_args()

    # read xml template
    doc = ET.parse(args.file)
    root = doc.getroot()

    # load rules
    rules = []
    for rule in ConversionRule.__subclasses__():
        rules.append(rule(root, args))

    # apply rules
    for rule in rules:
        try:
            rule.apply()
            debug('Applied: %s' % rule)
        except NotApplicableError:
            pass

    # print modified template
    print '<?xml version="1.0" encoding="UTF-8"?>'
    ET.dump(doc)

if __name__ == '__main__':
    __main__()
