#!/usr/bin/python
# -*- coding: utf-8 -*-
#
# Copyright © 2015, 2016 Collabora Ltd.
#
# This library is free software; you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the Free
# Software Foundation; either version 2.1 of the License, or (at your option)
# any later version.
#
# This library 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 Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this library.  If not, see <http://www.gnu.org/licenses/>.

"""
D-Bus interface comparator. This allows two D-Bus introspection XML files to be
compared for API compatibility, and any incompatibilities debugged.

Compatibility warnings are split into categories, separating forwards- and
backwards-compatibility in case one or other is not cared about by the
interface maintainer. See the documentation for InterfaceComparator for an
explanation of the two types of compatibility.
"""

import argparse
import os
import sys

from lxml import etree

from dbusapi.interfaceparser import InterfaceParser
from dbusdeviation.interfacecomparator import InterfaceComparator

# Warning categories.
WARNING_CATEGORIES = [
    'info',
    'backwards-compatibility',
    'forwards-compatibility',
    'parser',
]


def _parse_file(filename, parser):
    """
    Parse an XML interface file.

    Return a list of ast.Interfaces. If the file is empty, return an empty
    list. Print an error and exit if parsing fails.
    """
    try:
        interfaces = parser.parse()

        # Handle parse errors.
        if interfaces is None:
            _print_output(parser.get_output())
            sys.exit(1)

        return interfaces
    # pylint: disable=E1101
    except etree.XMLSyntaxError as err:
        # If the file is empty, treat it as a non-existent Interface. This
        # allows for diffs of added files.
        if os.path.getsize(filename) == 0:
            return {}
        else:
            sys.stderr.write('%s: error: %s\n' % (filename, err))
            sys.exit(1)


def _format_level(level, enable_colour=True, justified_length=0):
    """Format a warning level as a human-readable string."""
    colours = {
        'info': '\033[96;1m',
        'forwards-compatibility': '\033[35;1m',
        'backwards-compatibility': '\033[91;1m',
        'parser': '\033[91;1m',
    }
    formats = {
        'info': 'note',
        'forwards-compatibility': 'warn',
        'backwards-compatibility': 'error',
        'parser': 'error',
    }

    out = formats[level]

    if justified_length > 0:
        out = out.rjust(justified_length)
    if enable_colour:
        out = colours[level] + out + '\033[0m'

    return out


def _get_fd_for_level(level):
    """Get the output file descriptor to use for the output level."""
    if level == InterfaceComparator.OUTPUT_INFO:
        return sys.stdout
    return sys.stderr


def _print_output(output, include_uris=True, enable_colour=True):
    """
    Print all the log messages generated by the latest call to compare().

    The messages will be printed to stdout and/or stderr as appropriate.
    """
    # Justify the error codes.
    try:
        max_code_len = max([len(o[2]) for o in output])
        max_level_len = max([len(_format_level(o[1], False)) for o in output])
    except ValueError:
        max_code_len = 0
        max_level_len = 0

    for (filename, level, code, message) in output:
        formatted_level = _format_level(level, enable_colour, max_level_len)
        fd_for_level = _get_fd_for_level(level)
        errors_page = 'https://tecnocode.co.uk/dbus-deviation/errors.html'
        explanation_uri = '%s#%s' % (errors_page, code)
        formatted_code = code.rjust(max_code_len)

        if enable_colour:
            formatted_code = '\033[1m%s\033[0m' % formatted_code
            explanation_uri = '\033[90m%s\033[0m' % explanation_uri
        else:
            formatted_code = formatted_code

        if filename is None:
            output = '%s: %s: %s\n' % \
                     (formatted_level, formatted_code, message)
        else:
            output = '%s: %s: %s: %s\n' % \
                     (filename, formatted_level, formatted_code, message)

        fd_for_level.write(output)
        if include_uris:
            fd_for_level.write('   %s\n' % explanation_uri)


def _calculate_exit_status(args, output):
    """
    Build the exit status for the program.

    This will be non-zero if any errors were outputted, or if --fatal-warnings
    was passed and any warnings were outputted. Otherwise it will be zero.
    """
    outputted_errors = False
    outputted_warnings = False

    for (_, level, _, _) in output:
        if level in ['backwards-compatibility', 'parser']:
            outputted_errors = True
        elif level in ['forwards-compatibility']:
            outputted_warnings = True

    if outputted_errors or (args.fatal_warnings and outputted_warnings):
        return len(output)

    return 0


def main():
    """Main utility implementation."""
    codes = InterfaceComparator.get_output_codes()
    codes += InterfaceParser.get_output_codes()

    # Parse command line arguments.
    parser = argparse.ArgumentParser(
        description='Comparing D-Bus interface definitions')
    parser.add_argument('old_file', type=str, help='Old interface XML file')
    parser.add_argument('new_file', type=str, help='New interface XML file')
    parser.add_argument('--file-display-name', dest='file_display_name',
                        type=str,
                        help='Human-readable name for new interface XML file '
                             '(if --new-file is a pipe, for example)')
    parser.add_argument('--fatal-warnings', action='store_const', const=True,
                        default=False, help='Treat all warnings as fatal')
    parser.add_argument('--warnings', dest='warnings', metavar='CATEGORY,…',
                        type=str,
                        help='Warning categories (%s; %s)' %
                             (', '.join(WARNING_CATEGORIES),
                              ', '.join(codes)))

    args = parser.parse_args()

    if not args.old_file or not args.new_file:
        parser.print_help()
        sys.exit(1)

    if args.warnings is None or args.warnings == 'all':
        # Enable all warnings by default
        warnings_args = WARNING_CATEGORIES
    elif args.warnings == 'none':
        warnings_args = []
    else:
        warnings_args = args.warnings.split(',')

    for warning_arg in warnings_args:
        if warning_arg[:3] == 'no-':
            warning_arg = warning_arg[3:]
        if warning_arg not in WARNING_CATEGORIES and warning_arg not in codes:
            sys.stderr.write('%s: Unrecognized warning ‘%s’.\n' %
                             (sys.argv[0], warning_arg))
            parser.print_help()
            sys.exit(1)

    enabled_warnings = [arg for arg in warnings_args if arg[:3] != 'no-']
    disabled_warnings = [arg[3:] for arg in warnings_args if arg[:3] == 'no-']

    # Parse the two files.
    old_parser = InterfaceParser(args.old_file)
    new_parser = InterfaceParser(args.new_file)

    old_interfaces = _parse_file(args.old_file, old_parser)
    new_interfaces = _parse_file(args.new_file, new_parser)

    # Work out the human-readable name of the new XML filename.
    if args.file_display_name is not None:
        new_filename = args.file_display_name
    else:
        new_filename = args.new_file

    # Compare the interfaces.
    comparator = InterfaceComparator(old_interfaces, new_interfaces,
                                     enabled_warnings, disabled_warnings,
                                     new_filename)
    out = comparator.compare()
    _print_output(out)
    sys.exit(_calculate_exit_status(args, out))


if __name__ == '__main__':
    main()
