#!/usr/bin/env python3
#
# Copyright (C) 2018-present MongoDB, Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the Server Side Public License, version 1,
# as published by MongoDB, Inc.
#
# This program 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
# Server Side Public License for more details.
#
# You should have received a copy of the Server Side Public License
# along with this program. If not, see
# <http://www.mongodb.com/licensing/server-side-public-license>.
#
# As a special exception, the copyright holders give permission to link the
# code of portions of this program with the OpenSSL library under certain
# conditions as described in each individual source file and distribute
# linked combinations including the program with the OpenSSL library. You
# must comply with the Server Side Public License in all respects for
# all of the code used other than as permitted herein. If you modify file(s)
# with this exception, you may extend this exception to your version of the
# file(s), but you are not obligated to do so. If you do not wish to do so,
# delete this exception statement from your version. If you delete this
# exception statement from all source files in the program, then also delete
# it in the license file.
"""Generate source files from a specification of error codes and categories."""

import argparse
import os
import sys

import yaml
from Cheetah.Template import Template

help_epilog = """
The error_codes_spec YAML document is a mapping containing two toplevel fields:

    `error_categories`: sequence of string - The error category names

    `error_codes`: sequence of map - Each map consists of:
          `code`: scalar - error's integer value
          `name`: scalar - error's string name
          `extra`: (optional) scalar - C++ class name for holding ErrorExtraInfo.
          `categories`: (optional) sequence of strings - each must appear in `error_categories`.
          `extraIsOptional': (optional) boolean - determines if ErrorExtraInfo can be optional for
                            the ErrorCode.
"""


def init_parser():
    global parser
    parser = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter,
        description=__doc__,
        epilog=help_epilog,
    )
    parser.add_argument("--verbose", action="store_true", help="extra debug logging to stderr")
    parser.add_argument("error_codes_spec", help="YAML file describing error codes and categories")
    parser.add_argument("template_file", help="Cheetah template file")
    parser.add_argument("output_file")


verbose = False


def render_template(template_path, **kw):
    """Renders the template file located at template_path, using the variables defined by kw, and
    returns the result as a string"""

    template = Template.compile(
        file=template_path,
        compilerSettings=dict(
            directiveStartToken="//#", directiveEndToken="//#", commentStartToken="//##"
        ),
        baseclass=dict,
        useCache=False,
    )
    return str(template(**kw))


class ErrorCode:
    def __init__(self, name, code, extra=None, extraIsOptional=False):
        self.name = name
        self.code = code
        self.extra = extra
        self.extraIsOptional = extraIsOptional
        if extra:
            split = extra.split("::")
            if not split[0]:
                die(
                    "Error for %s with extra info %s: fully qualified namespaces aren't supported"
                    % (name, extra)
                )
            if split[0] == "mongo":
                die(
                    "Error for %s with extra info %s: don't include the mongo namespace"
                    % (name, extra)
                )
            if len(split) > 1:
                self.extra_class = split.pop()
                self.extra_ns = "::".join(split)
            else:
                self.extra_class = extra
                self.extra_ns = None
        self.categories = []


class ErrorClass:
    def __init__(self, name, codes):
        self.name = name
        self.codes = codes


def main():
    init_parser()
    parsed = parser.parse_args()
    global verbose
    verbose = parsed.verbose
    error_codes_spec = parsed.error_codes_spec
    template_file = parsed.template_file
    output_file = parsed.output_file

    os.makedirs(os.path.dirname(output_file), exist_ok=True)

    # Parse and validate error_codes.yml
    error_codes, error_classes = parse_error_definitions_from_file(error_codes_spec)
    check_for_conflicts(error_codes, error_classes)

    # Render the templates to the output files.
    if verbose:
        print(f"rendering {template_file} => {output_file}")
    text = render_template(
        template_file,
        codes=error_codes,
        categories=error_classes,
    )
    with open(output_file, "w") as outfile:
        outfile.write(text)


def die(message=None):
    sys.stderr.write(message or "Fatal error\n")
    sys.exit(1)


def usage(message=None):
    parser.error(message)
    # writes a usage message and exits the program dies


def parse_error_definitions_from_file(errors_filename):
    error_codes = []
    error_classes = []
    with open(errors_filename, "r") as errors_file:
        doc = yaml.safe_load(errors_file)

    if verbose:
        yaml.dump(doc, sys.stderr)

    cats = {}
    for v in doc["error_categories"]:
        cats[v] = []

    for v in doc["error_codes"]:
        assert type(v) is dict
        name, code = v["name"], v["code"]
        extraIsOptional = False

        if "extraIsOptional" in v:
            extraIsOptional = v["extraIsOptional"]

        if "categories" in v:
            for cat in v["categories"]:
                assert cat in cats, f"invalid category {cat} for code {name}"
                cats[cat].append(name)

        kw = {}
        if "extra" in v:
            kw["extra"] = v["extra"]

        error_codes.append(ErrorCode(name, code, **kw, extraIsOptional=extraIsOptional))

    for cat, members in cats.items():
        error_classes.append(ErrorClass(cat, members))

    error_codes.sort(key=lambda x: x.code)

    return error_codes, error_classes


def check_for_conflicts(error_codes, error_classes):
    failed = has_duplicate_error_codes(error_codes)
    if has_duplicate_error_classes(error_classes):
        failed = True
    if has_missing_error_codes(error_codes, error_classes):
        failed = True
    if failed:
        die()


def has_duplicate_error_codes(error_codes):
    sorted_by_name = sorted(error_codes, key=lambda x: x.name)
    sorted_by_code = sorted(error_codes, key=lambda x: x.code)

    failed = False
    prev = sorted_by_name[0]
    for curr in sorted_by_name[1:]:
        if curr.name == prev.name:
            sys.stdout.write(
                "Duplicate name %s with codes %s and %s\n" % (curr.name, curr.code, prev.code)
            )
            failed = True
        prev = curr

    prev = sorted_by_code[0]
    for curr in sorted_by_code[1:]:
        if curr.code == prev.code:
            sys.stdout.write(
                "Duplicate code %s with names %s and %s\n" % (curr.code, curr.name, prev.name)
            )
            failed = True
        prev = curr

    return failed


def has_duplicate_error_classes(error_classes):
    names = sorted(ec.name for ec in error_classes)

    failed = False
    prev_name = names[0]
    for name in names[1:]:
        if prev_name == name:
            sys.stdout.write("Duplicate error class name %s\n" % name)
            failed = True
        prev_name = name
    return failed


def has_missing_error_codes(error_codes, error_classes):
    code_names = dict((ec.name, ec) for ec in error_codes)
    failed = False
    for category in error_classes:
        for name in category.codes:
            try:
                code_names[name].categories.append(category.name)
            except KeyError:
                sys.stdout.write("Undeclared error code %s in class %s\n" % (name, category.name))
                failed = True

    return failed


if __name__ == "__main__":
    main()
