#!/usr/bin/python

from __future__ import print_function

import pprint
import sys


if sys.version_info.major != 3:
  raise Exception("Only use python3 to execute this script")
TEST_COUNT = 0
PLATFORM_ARCH_32 = False

CLASSES = { "new_debians": "(debian.!ubuntu_10.!debian_6)",
            "old_debians_32_bit": "(32_bit.(ubuntu_10|debian_6))", # No multiarch support.
            "old_debians_64_bit": "(64_bit.(ubuntu_10|debian_6))", # No multiarch support.
            "redhat_5": "(redhat_5|centos_5)",
            "redhat_6_or_newer": "(redhat.!redhat_5.!centos_5)",
          }

class PromiseFailureException(Exception):
    pass
class NotSupportedException(Exception):
    pass

states = {
    "absent": {
        ( "64_bit", "1" ): False,
        ( "64_bit", "2" ): False,
        ( "32_bit", "1" ): False,
        ( "32_bit", "2" ): False,
    },
    "64_bit_1": {
        ( "64_bit", "1" ): True,
        ( "64_bit", "2" ): False,
        ( "32_bit", "1" ): False,
        ( "32_bit", "2" ): False,
    },
    "64_bit_2": {
        ( "64_bit", "1" ): False,
        ( "64_bit", "2" ): True,
        ( "32_bit", "1" ): False,
        ( "32_bit", "2" ): False,
    },
    "32_bit_1": {
        ( "64_bit", "1" ): False,
        ( "64_bit", "2" ): False,
        ( "32_bit", "1" ): True,
        ( "32_bit", "2" ): False,
    },
    "32_bit_2": {
        ( "64_bit", "1" ): False,
        ( "64_bit", "2" ): False,
        ( "32_bit", "1" ): False,
        ( "32_bit", "2" ): True,
    },
    "64_bit_1_32_bit_1": {
        ( "64_bit", "1" ): True,
        ( "64_bit", "2" ): False,
        ( "32_bit", "1" ): True,
        ( "32_bit", "2" ): False,
    },
    # These combinations are not possible, because different architecture
    # packages must be the same version.
    # "64_bit_1_32_bit_2": {
    #     ( "64_bit", "1" ): True,
    #     ( "64_bit", "2" ): False,
    #     ( "32_bit", "1" ): False,
    #     ( "32_bit", "2" ): True,
    # },
    # "64_bit_2_32_bit_1": {
    #     ( "64_bit", "1" ): False,
    #     ( "64_bit", "2" ): True,
    #     ( "32_bit", "1" ): True,
    #     ( "32_bit", "2" ): False,
    # },
    "64_bit_2_32_bit_2": {
        ( "64_bit", "1" ): False,
        ( "64_bit", "2" ): True,
        ( "32_bit", "1" ): False,
        ( "32_bit", "2" ): True,
    },
}


def header(test_count):
    print('''# THIS IS AN AUTOGENERATED TEST!
# DO NOT EDIT IT DIRECTLY!
#
# Instead, edit the_great_package_test_generator.py and use that to regenerate
# the test.
#
# Number of test cases: ''' + str(test_count) + '''
#
# If you want to run a specific test case, define a class with the name of that
# test, for example "from_absent_to_absent___promise_policy_absent_arch_64_bit".

body common control
{
    inputs => { "../../../dcs.cf.sub",
                "../../../../../controls/def.cf",
                "../../../../../$(sys.local_libdir)/packages.cf",
                "../../../../../$(sys.local_libdir)/commands.cf",
                "../../../../../cfe_internal/update/lib.cf",
                "../../../../../cfe_internal/update/update_policy.cf",
                "../../packages-info.cf.sub",
                "../../meta_skip.cf.sub",
              };
    bundlesequence => { default($(this.promise_filename)) };
  debian::
    package_module => "apt_get";
  redhat::
    package_module => "yum";
}

bundle agent init
{
  meta:
      # RHEL 9 may have similar issues as RHEL 8 and also issues where rpm -U --force
      # has different behavior than previous releases: erases other architecture
      # packages where earlier releases did not, so fails many tests.
      # RHEL 8 has broken DNF (upgrading a 32bit package also installs a 64bit
      # package)
      "test_soft_fail" string => "rhel_8|rhel_9",
        meta  => {"CFE-rhbz", "CFE-4096"};

  # For setting up the cfengine-selected-python symlink we want to
  # target $(sys.bindir) as that will be in the test WORKDIR.
  vars:
    "python_path" string => "$(sys.bindir)/cfengine-selected-python";


  methods:
    debian|redhat::
      "setup_python_symlink" usebundle => cfe_internal_setup_python_symlink("$(python_path)");

  files:
    "${sys.workdir}/modules/packages/."
      create => "true";

    debian::
      "${sys.workdir}/modules/packages/apt_get"
        copy_from => local_cp("${sys.workdir}/modules/packages/vendored/apt_get.mustache");
    redhat::
      "${sys.workdir}/modules/packages/yum"
        copy_from => local_cp("${sys.workdir}/modules/packages/vendored/yum.mustache");
}

bundle agent log_test_case(msg)
{
  reports:
      "-------------------------------------"
        comment => "$(msg)_1";
      "$(msg)"
        comment => $(msg);
      "-------------------------------------"
        comment => "$(msg)_2";
}
''')

def get_possible_promises():
    # All the possible promises, expressed in terms of policy, architecture,
    # three versions (1, 2 or latest), and file to install.
    # P     = policy => "present"
    # D     = policy => "absent"
    # F     = File install
    # R     = Repo install
    # F64_1 = File to install is 64 bit and version 1
    # A64   = architecture = "64_bit"
    # V1    = version => "1"
    #
    # This is used to generate the promise structure. It does not include
    # impossible promises, such as installing a 64-bit file and mentioning
    # architecture => "32_bit".
    text_promises = [ "PF64_1     ",
                      "PF64_2     ",
                      "PF32_1     ",
                      "PF32_2     ",
                      "PF64_1A64  ",
                      "PF64_2A64  ",
                      "PF32_1A32  ",
                      "PF32_2A32  ",
                      "PF64_1   V1",
                      "PF64_2   V2",
                      "PF32_1   V1",
                      "PF32_2   V2",
                      "PF64_1A64V1",
                      "PF64_2A64V2",
                      "PF32_1A32V1",
                      "PF32_2A32V2",
                      "PR         ",
                      "PR    A64  ",
                      "PR    A32  ",
                      "PR       V1",
                      "PR       V2",
                      "PR       VL",
                      "PR    A64V1",
                      "PR    A32V1",
                      "PR    A64V2",
                      "PR    A32V2",
                      "PR    A64VL",
                      "PR    A32VL",
                      "D          ",
                      "D     A64  ",
                      "D     A32  ",
                      "D        V1",
                      "D        V2",
                      "D     A64V1",
                      "D     A32V1",
                      "D     A64V2",
                      "D     A32V2",
                    ]

    promises = []
    for text in text_promises:
        promise = {}
        if text[0] != "P":
            promise["policy"] = "absent"
        else:
            promise["policy"] = "present"

            if text[1] != "F":
                promise["type"] = "repo"
            else:
                promise["type"] = "file"

                if text[2:4] == "64":
                    promise["file_arch"] = "64_bit"
                else:
                    promise["file_arch"] = "32_bit"

                if text[5] == "1":
                    promise["file_version"] = "1"
                else:
                    promise["file_version"] = "2"

        if text[6:9] == "A64":
            promise["arch"] = "64_bit"
        elif text[6:9] == "A32":
            promise["arch"] = "32_bit"

        if text[9:11] == "VL":
            promise["version"] = "latest"
        elif text[9] == "V":
            promise["version"] = text[10]

        promises.append(promise)

    return promises


# Given the transitions between the two version, determine if the change of one
# architecture package will trigger a change in the other. Only one of the two
# inputs can be different, but the returned value may cause both to be
# different. This is only relevant for "repo" style promises, since "file"
# promises will never touch more than one package at a time.
def resolve_arch_conflicts(cur_class, from_64, from_32, to_64, to_32):
    if cur_class.startswith("old_debians"):
        if (from_64 != "0" and from_32 != "0") or (to_64 != "0" and to_32 != "0"):
            raise PromiseFailureException("Not possible on old Debians (no multiarch)")

    if from_64 != to_64:
        if from_32 != "0":
            if cur_class == "debian":
                if to_64 >= from_32:
                    to_32 = to_64
                else:
                    to_32 = "0"
            elif cur_class.startswith("redhat"):
                if to_64 > from_32 and cur_class == "redhat_5":
                    to_32 = "0"
                elif to_64 < from_32:
                    raise PromiseFailureException("Not possible on rpm")

    elif from_32 != to_32:
        if from_64 != "0":
            if cur_class == "debian":
                if to_32 >= from_64:
                    to_64 = to_32
                else:
                    to_64 = "0"
            elif cur_class.startswith("redhat"):
                if to_32 > from_64 and cur_class == "redhat_5":
                    to_64 = "0"
                elif to_32 < from_64:
                    raise PromiseFailureException("Not possible on rpm")

    return to_64, to_32


def check_allowed_archs(cur_class, pkg_64, pkg_32):
    if cur_class == "old_debians_32_bit" and pkg_64 != "0":
        raise NotSupportedException("Only native architecture supported")
    if cur_class == "old_debians_64_bit" and pkg_32 != "0":
        raise NotSupportedException("Only native architecture supported")


# Simulate, with the given promise, what the state of the system would be,
# if we started from the versions in from_64 and from_32.
def simulate_promise(promise, cur_class, from_64, from_32):
    to_64 = from_64
    to_32 = from_32

    check_allowed_archs(cur_class, from_64, from_32)

    arch, version = promise.get('arch'), promise.get('version')
    if promise["policy"] == "present":
        if promise["type"] == "repo":
            if version == "latest":
                version = "2"

            if arch == "64_bit":
                if version:
                    to_64 = version
                elif from_64 == "0":
                    to_64 = "2"

                to_64, to_32 = resolve_arch_conflicts(cur_class, from_64, from_32, to_64, to_32)

            elif arch == "32_bit":
                if version:
                    to_32 = version
                elif from_32 == "0":
                    to_32 = "2"

                to_64, to_32 = resolve_arch_conflicts(cur_class, from_64, from_32, to_64, to_32)

            elif version:

                if from_64 == "0" and from_32 == "0":
                    if cur_class == "redhat_5":
                        to_32 = version
                    to_64 = version

                if from_64 != "0":
                    to_64 = version

                if from_32 != "0":
                    to_32 = version

            elif from_64 == "0" and from_32 == "0":
                if cur_class == "redhat_5":
                    to_32 = "2"
                to_64 = "2"

        else:
            # Todo: Errors when file doesn't match arch/version?
            if promise["file_arch"] == "64_bit":
                to_64 = promise["file_version"]
            else:
                to_32 = promise["file_version"]

    else:
        if arch == "64_bit":
            if not version or version == from_64:
                to_64 = "0"
        elif arch == "32_bit":
            if not version or version == from_32:
                to_32 = "0"
        else:
            if version:
                if version == from_64:
                    to_64 = "0"
                if version == from_32:
                    to_32 = "0"
            else:
                to_64 = "0"
                to_32 = "0"

    check_allowed_archs(cur_class, to_64, to_32)

    return to_64, to_32


# Calculate all possible transitions from from_state, to to_state.
def calc_transitions(from_state, to_state):
    from_64 = "0"
    if states[from_state][("64_bit", "1")]:
        from_64 = "1"
    elif states[from_state][("64_bit", "2")]:
        from_64 = "2"

    from_32 = "0"
    if states[from_state][("32_bit", "1")]:
        from_32 = "1"
    elif states[from_state][("32_bit", "2")]:
        from_32 = "2"

    to_64 = "0"
    if states[to_state][("64_bit", "1")]:
        to_64 = "1"
    elif states[to_state][("64_bit", "2")]:
        to_64 = "2"

    to_32 = "0"
    if states[to_state][("32_bit", "1")]:
        to_32 = "1"
    elif states[to_state][("32_bit", "2")]:
        to_32 = "2"

    promise_candidates = get_possible_promises()
    valid_promises = []

    for promise in promise_candidates:
        classes = []
        failing_classes = []
        for cur_class in CLASSES:
            try:
                result = simulate_promise(promise, cur_class, from_64, from_32)
                if result[0] == to_64 and result[1] == to_32:
                    classes.append(CLASSES[cur_class])
            except PromiseFailureException:
                failing_classes.append(CLASSES[cur_class])
            except NotSupportedException:
                pass
        if classes:
            promise["classes"] = "|".join(classes)
            valid_promises.append(promise.copy())
            # Only consider failing cases in case at least one other platform
            # passes, otherwise we get way too many test cases. We don't need to
            # test every possible failing case.
            if failing_classes:
                promise["failing"] = "1"
                promise["classes"] = "|".join(failing_classes)
                valid_promises.append(promise.copy())

    return valid_promises


# Calculate all possible transitions from all states to all other states,
# including the same state.
def calc_all_transitions():
    transitions = {}
    for from_state in states:
        for to_state in states:
            transitions[(from_state, to_state)] = calc_transitions(from_state, to_state)

    return transitions


def formatted_version(version):
    if version == "latest":
        return "latest"
    else:
        return "$(p.version[" + version + "])"


# Write one test case, using one promise to go from one state to another (or the
# same) state.
# This function is quite hard to read because of all the quoting going on, it
# might be easier to look at the output it produces.
def make_test(current_count, total_test_count, from_state, to_state, transition, test_handle, warn_only):
    if states[from_state][("64_bit", "1")] or states[from_state][("64_bit", "2")] or states[to_state][("64_bit", "1")] or states[to_state][("64_bit", "2")]:
        arch_prefix = "64_bit."
    else:
        arch_prefix = ""

    print('''bundle agent ''' + test_handle + '''
{
  methods:
    ''' + arch_prefix + "(" + transition["classes"] + ''')::
      "''' + test_handle + '''_start_msg"
        usebundle => log_test_case("''' + str((current_count * 100) / total_test_count) + '''%: Starting test case \\"''' + test_handle + '''\\"");
      "''' + test_handle + '''_init"
        usebundle => ''' + test_handle + '''_init;
      "''' + test_handle + '''_test"
        usebundle => ''' + test_handle + '''_test;
      "''' + test_handle + '''_check"
        usebundle => ''' + test_handle + '''_check;
      "''' + test_handle + '''_finish_msg"
        usebundle => log_test_case("''' + str((current_count * 100) / total_test_count) + '''%: Finished test case \\"''' + test_handle + '''\\"");

  classes:
    trigger::
      "''' + test_handle + '''_ok"
        not => "''' + arch_prefix + "(" + transition["classes"] + ''')",
        scope => "namespace";
    any::
      "trigger" expression => "any";
}

bundle agent ''' + test_handle + '''_init
{
  methods:
      "clear_packages" usebundle => clear_packages("''' + test_handle + '''");
      "clear_package_cache" usebundle => clear_package_cache("''' + test_handle + '''");''')
    for arch in ["64_bit", "32_bit"]:
        for version in ["1", "2"]:
            if states[from_state][(arch, version)]:
                print("      \"install_package\" usebundle => install_package($(p.name[1]), "
                      + formatted_version(version) + ", $(p." + arch + "), \"" + test_handle + "\");")
    print("}\n")

    print('''bundle agent ''' + test_handle + '''_test
{
  packages:''')
    if transition["policy"] == "absent" or transition["type"] == "repo":
        print("      \"$(p.name[1])\"")
    else:
        print("      \"$(p.package[1][" + transition["file_version"] + "][" + transition["file_arch"] + "])\"")
    print("        policy => \"" + transition["policy"] + "\"")
    if transition.get("arch"):
        print("      , architecture => \"$(p." + transition["arch"] + ")\"")
    if transition.get("version"):
        print("      , version => \"" + formatted_version(transition["version"]) + "\"")
    if warn_only:
        print("      , action => warn_only")
    print("      , classes => classes_generic(\"" + test_handle + "___class\")")
    print('''      ;
}

bundle agent ''' + test_handle + '''_check
{
  classes:
      "''' + test_handle + '''___correct_classes"''')
    if from_state == to_state and not transition.get("failing"):
        print("        expression => \"" + test_handle + "___class_kept.!" + test_handle + "___class_repaired.!" + test_handle + "___class_failed\";")
    elif warn_only or transition.get("failing"):
        print("        expression => \"!" + test_handle + "___class_kept.!" + test_handle + "___class_repaired." + test_handle + "___class_failed\";")
    else:
        print("        expression => \"!" + test_handle + "___class_kept." + test_handle + "___class_repaired.!" + test_handle + "___class_failed\";")

    if warn_only or transition.get("failing"):
        file_state_to_check = from_state
    else:
        file_state_to_check = to_state
    for arch in ["64_bit", "32_bit"]:
        for version in ["1", "2"]:
            print("      \"" + arch + "_" + version + "\"")
            if states[file_state_to_check][(arch, version)]:
                print("        expression => fileexists(\"$(p.file[1][" + version + "][" + arch + "])\");")
            else:
                print("        not => fileexists(\"$(p.file[1][" + version + "][" + arch + "])\");")
    print('''      "''' + test_handle + '''_ok"
        scope => "namespace",
        and => {''')
    for arch in ["64_bit", "32_bit"]:
        for version in ["1", "2"]:
            print("          \"" + arch + "_" + version + "\",")
    print('''          "''' + test_handle + '''___correct_classes"
        };
''')

    print('''  reports:
    !''' + test_handle + '''_ok::
      "FAILED: ''' + test_handle + '''";
    ''' + test_handle + '''_ok::
      "PASS: ''' + test_handle + '''";
  commands:
    !''' + test_handle + '''_ok::
      "$(G.echo) 'Contents of \\"/$(p.name[1])*\\"' && $(G.ls) /$(p.name[1])*"
        contain => in_shell;

  reports:''')

    if (from_state != to_state and warn_only) or transition.get("failing"):
        print('''    !''' + test_handle + '''___class_failed::
      "Class was not set, but should be: ''' + test_handle + '''___class_failed";''')
    else:
        print('''    ''' + test_handle + '''___class_failed::
      "Class was set, but should not be: ''' + test_handle + '''___class_failed";''')

    if from_state != to_state or transition.get("failing"):
        print('''    ''' + test_handle + '''___class_kept::
      "Class was set, but should not be: ''' + test_handle + '''___class_kept";''')
    else:
        print('''    !''' + test_handle + '''___class_kept::
      "Class was not set, but should be: ''' + test_handle + '''___class_kept";''')

    if from_state != to_state and not warn_only and not transition.get("failing"):
        print('''    !''' + test_handle + '''___class_repaired::
      "Class was not set, but should be: ''' + test_handle + '''___class_repaired";''')
    else:
        print('''    ''' + test_handle + '''___class_repaired::
      "Class was set, but should not be: ''' + test_handle + '''___class_repaired";''')

    print('''}
''')


# Print the main test bundles.
def main_bundles(test_handles):
    print('''bundle agent test
{
  classes:
      "specific_test_case_specified" or => {''')
    for handle in test_handles:
        print("          \"" + handle + "\",")
    print('''      };
      "run_all_tests"
        not => "specific_test_case_specified",
        scope => "namespace";

  methods:''')
    for handle in test_handles:
        print("    run_all_tests|" + handle + "::")
        print("      \"" + handle + "\"\n        usebundle => " + handle + ";")
    print("}")

    print('''bundle agent check
{
  classes:
      "ok" and => {''')
    # Enable test to pass even when running only a sub test.
    for handle in test_handles:
        print("          \"" + handle + "_ok|(!run_all_tests.!" + handle + ")\",")
    print('''        };

  reports:
    !ok::
      "$(this.promise_filename) FAIL";
    ok::
      "$(this.promise_filename) Pass";
}''')



transitions = calc_all_transitions()
test_count = 0
test_handles = []
current_count = 0
# One pass to count tests, one pass to actually output them.
for op in ["count", "do"]:
    if op == "do":
        header(test_count)
    for i in transitions:
        for j in transitions[i]:
            if op == "count":
                test_count += 1
            else:
                current_count += 1
                test_handle = "from_" + i[0] + "_to_" + i[1] + "___promise_" + "_".join([k + "_" + j[k] for k in j if k != "classes"])
                test_handles.append(test_handle)
                make_test(current_count, test_count, i[0], i[1], j, test_handle, False)

            # Cut down on testing time by not testing "warn_only" together with
            # equal states. "warn_only" has no effect there, since there is no
            # change to begin with.
            if i[0] == i[1]:
                continue

            if op == "count":
                test_count += 1
            else:
                current_count += 1
                test_handle += "_warn_only"
                test_handles.append(test_handle)
                make_test(current_count, test_count, i[0], i[1], j, test_handle, True)

main_bundles(test_handles)
