#!/usr/bin/env python3
"""
    CIJOE Test Linter

    * Verifies that testplans can be parsed from Yaml to dict
    * Verifies that testplans contains required content
    * Verifies that testcases and hooks used exists
    * Verifies that testcases that exists are used

    And possibly more... e.g. the test linter runs through CIJOE testplans,
    testsuites and testcases looking for common mistakes
"""
from __future__ import print_function
import argparse
import sys
import os
import copy
import yaml
import cij.conf
import cij
from cij.analyser import Range
from cij.errors import CIJError


def _load_tplans(ident, roots, fnames):
    """Returns a list of successfully loadable testplans and a list of
    violations for those that failed"""

    tplans = {}
    violations = []

    for tp_fname in fnames["TPLANS"]:
        tp_fpath = os.path.join(roots["TPLANS"], tp_fname)

        tplan = None
        try:
            with open(tp_fpath, encoding="UTF-8") as tp_fd:
                tplan = yaml.safe_load(tp_fd)
        except yaml.YAMLError:
            violations.append(MESSAGES[ident] % (tp_fname, "invalid YAML"))
            continue
        except IOError:
            violations.append(MESSAGES[ident] % (tp_fname, "IO error"))
            continue

        if not isinstance(tplan, dict):
            violations.append(MESSAGES[ident] % (
                tp_fname,
                "could not be parsed as dictionary"
            ))
            continue

        tplans[tp_fname] = tplan

    return tplans, violations


def _load_preqs(ident, roots, fnames):
    """
    Returns a list of successfully loadable preqs (performance requirements)
    files and a list of violations for those that failed.
    """

    preqs = {}
    violations = []

    for preq_fname in fnames["PREQS"]:
        preq_fpath = os.path.join(roots["PREQS"], preq_fname)

        preq = None
        try:
            with open(preq_fpath, encoding="UTF-8") as tp_fd:
                preq = yaml.safe_load(tp_fd)
        except yaml.YAMLError:
            violations.append(MESSAGES[ident] % (preq_fname, "invalid YAML"))
            continue
        except IOError:
            violations.append(MESSAGES[ident] % (preq_fname, "IO error"))
            continue

        if not isinstance(preq, dict):
            violations.append(MESSAGES[ident] % (
                preq_fname,
                "could not be parsed as dictionary"
            ))
            continue

        preqs[preq_fname] = preq

    return preqs, violations


def tsuitefile_testcases_exists(ident, roots, fnames):
    """
    @returns list of violation messages for this check
    """

    violations = []

    for ts_fname in fnames["TSUITES"]:    # Search .suite files
        ts_fpath = os.path.join(roots["TSUITES"], ts_fname)
        with open(ts_fpath, encoding="UTF-8") as tsfd:
            ts_lines_all = (
                line.strip() for line in tsfd.read().splitlines()
            )
            ts_lines = (
                line for line in ts_lines_all
                if len(line) > 1 and line[0] != "#"
            )

            for tc_fname in ts_lines:
                if tc_fname in fnames["TCASES"]:
                    continue

                violations.append(MESSAGES[ident] % (tc_fname, ts_fpath))

    return violations


def testcase_unused(ident, roots, fnames):
    """
    @returns list of violation messages for this check
    """

    violations = []

    tcases_in_use = set([])

    for ts_fname in fnames["TSUITES"]:  # Testcases from suite-files
        ts_fpath = os.path.join(roots["TSUITES"], ts_fname)
        with open(ts_fpath, encoding="UTF-8") as tsfd:
            ts_lines_all = (
                line.strip() for line in tsfd.read().splitlines()
            )
            ts_lines = (
                line for line in ts_lines_all
                if len(line) > 1 and line[0] != "#"
            )

            for tc_fname in ts_lines:
                tcases_in_use.add(tc_fname)

    tplans, _ = _load_tplans(ident, roots, fnames)  # testcases inline
    for _, tplan in tplans.items():
        for testsuite in tplan.get("testsuites", []):
            tcases_in_use.update(testsuite.get("testcases", []))

    for tc_fname in sorted(list(fnames["TCASES"] - tcases_in_use)):
        violations.append(MESSAGES[ident] % (tc_fname, ""))

    return violations


def tplans_format(ident, roots, fnames):
    """
    @returns list of violation messages for this check
    """

    _, violations = _load_tplans(ident, roots, fnames)

    return violations


def tplan_testcases_exists(tp_fname, ident, fnames, tcase_fnames):
    """
    @returns list of violation messages for this check
    """

    violations = []

    for tcase_fname in tcase_fnames:
        if tcase_fname not in fnames["TCASES"]:
            violations.append(MESSAGES[ident] % (
                tp_fname,
                "use of non-extent testcase(%s)" % tcase_fname
            ))

    return violations


def tplans_content(ident, roots, fnames):
    """
    @returns list of violations messages for this check
    """

    violations = []

    struct = {
        "root": [
            ("descr", True),
            ("descr_long", False),
            ("hooks", False),
            ("evars", False),
            ("testsuites", True)
        ],
        "suites": [
            ("name", True),
            ("alias", False),
            ("hooks", False),
            ("hooks_pr_tcase", False),
            ("evars", False),
            ("evars_pr_tcase", False),
            ("testcases", False),
        ]
    }

    tplans, _ = _load_tplans(ident, roots, fnames)
    for tp_fname, tplan in tplans.items():

        for k in set(tplan.keys()) - set(k for k, _ in struct["root"]):
            violations.append(MESSAGES[ident] % (
                tp_fname,
                "invalid key: %r" % k
            ))

        for k in (k for k, req in struct["root"] if req):
            if k not in tplan.keys():
                violations.append(MESSAGES[ident] % (
                    tp_fname,
                    "missing required key: %r" % k
                ))

        hooks = tplan.get("hooks", [])

        if "testsuites" not in tplan:
            violations.append(MESSAGES[ident] % (
                tp_fname,
                "missing key 'testsuites'"
            ))
            continue

        for suite in tplan["testsuites"]:
            for k in set(suite.keys()) - set(k for k, _ in struct["suites"]):
                violations.append(MESSAGES[ident] % (
                    tp_fname,
                    "invalid key: %r" % k
                ))

            for k in (k for k, req in struct["suites"] if req):
                if k not in suite.keys():
                    violations.append(MESSAGES[ident] % (
                        tp_fname,
                        "missing required key: %r" % k
                    ))

            hooks.extend(suite.get("hooks", []))
            hooks.extend(suite.get("hooks_pr_tcase", []))

            # A file is only required when 'testcases' are not defined inline
            if "testcases" not in suite and "%s.suite" % \
                    suite.get("name", "jank") not in fnames["TSUITES"]:
                violations.append(MESSAGES[ident] % (
                    tp_fname,
                    "testsuite: no testcases in '%s'" % suite["name"]
                ))
                violations += tplan_testcases_exists(
                    tp_fname, ident, fnames, suite.get("testcases", [])
                )

        for hname in set(hooks):        # Check for existence of hooks
            tmpls = ["%s.sh", "%s_enter.sh", "%s_exit.sh"]
            if not sum(tmpl % hname in fnames["HOOKS"] for tmpl in tmpls):
                violations.append(MESSAGES[ident] % (
                    tp_fname,
                    "hook: %r, does not exist" % hname
                ))

    return violations


def _fname_to_name(fname):
    return os.path.splitext(os.path.basename(fname))[0]


def preqs_content(ident, roots, fnames):
    """
    Verifies that the testsuites and testcases referenced in .preqs files
    exist. Also verifies that the ranges specified for given metrics are valid.

    @returns list of violations messages for this check
    """
    # pylint: disable=too-many-locals

    preqs, violations = _load_preqs(ident, roots, fnames)

    global_fields = {"metrics", "testcases"}

    tsuite_names = {_fname_to_name(fname) for fname in fnames["TSUITES"]}
    tcase_names = {_fname_to_name(fname) for fname in fnames["TCASES"]}

    def validate_range(fname, rng):
        try:
            Range(rng)
        except (TypeError, CIJError) as ex:
            violations.append(MESSAGES[ident] % (
                fname,
                "range: '%s' is invalid: %s" % (rng, ex),
            ))

    for preq_fname, preq in preqs.items():
        root_keys = copy.deepcopy(preq)
        global_ = root_keys.pop("global", {})

        # check if invalid keys are used in global
        for invalid_key in set(global_) - global_fields:
            violations.append(MESSAGES[ident] % (
                preq_fname,
                "global: key %r not valid" % invalid_key,
            ))

        # check that global.metric ranges are valid
        for rng in global_.get("metrics", {}).values():
            validate_range(preq_fname, rng)

        # check that referenced global.testcases exist
        global_testcases = global_.get("testcases", {})
        for nonexisting_tcase in set(global_testcases) - tcase_names:
            violations.append(MESSAGES[ident] % (
                preq_fname,
                "tcase: %r does not exist" % nonexisting_tcase,
            ))

        # check that global.testcases ranges are valid
        for metrics in global_testcases.values():
            for rng in metrics.values():
                validate_range(preq_fname, rng)

        # check if referenced root testsuites exist
        for nonexisting_tsuite in set(root_keys) - tsuite_names:
            violations.append(MESSAGES[ident] % (
                preq_fname,
                "tsuite: %r does not exist" % nonexisting_tsuite,
            ))

        # check if referenced root testsuites.testcases exist
        for tcases in root_keys.values():
            for nonexisting_tcase in set(tcases) - tcase_names:
                violations.append(MESSAGES[ident] % (
                    preq_fname,
                    "tcase: %r does not exist" % nonexisting_tcase,
                ))

            # check that root testsuites.testcases ranges are valid
            for preqs in tcases.values():
                for rng in preqs.values():
                    validate_range(preq_fname, rng)

    return violations


MESSAGES = {
    "E0100": "Use of non-existent testcase(%s) in testsuite(%s)",
    "E0200": "Invalid testplan(%s) msg: %r",
    "E0210": "Invalid testplan(%s) content: %r",
    "E0300": "Invalid performance requirements(%s) msg: %r",
    "W0100": "Unused testcase(%s) msg: %r",
}

CHECKERS = {
    "E0100": tsuitefile_testcases_exists,
    "E0200": tplans_format,
    "E0210": tplans_content,
    "E0300": preqs_content,
    "W0100": testcase_unused
}

IDENTS = sorted(CHECKERS.keys())


def main(roots):
    """."""

    fnames = {ext: cij.index(roots[ext], ext) for ext in cij.EXTS}

    report = []
    for ident in IDENTS:
        res = CHECKERS[ident](ident, roots, fnames)
        report += zip([ident] * len(res), res)

    errors = 0
    warnings = 0
    for ident, violation in report:
        msg = "%s: %s" % (ident, violation)

        if ident.startswith("E"):
            errors += 1
            cij.err(msg)
        elif ident.startswith("W"):
            warnings += 1
            cij.warn(msg)
        else:
            cij.emph(msg)

    return errors


if __name__ == "__main__":

    CONF = cij.conf.from_system()
    if CONF is None:
        cij.err("rprt: failed retrieving CIJOE configuration")
        sys.exit(1)

    PRSR = argparse.ArgumentParser(
        description="cij_tlint - CIJOE Test Linter",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter
    )
    ARGS = PRSR.parse_args()

    ROOTS = {
        "TPLANS": CONF.testplans,
        "TSUITES": CONF.testsuites,
        "TCASES": CONF.testcases,
        "HOOKS": CONF.hooks,
        "PREQS": CONF.testfiles,
    }

    sys.exit(main(ROOTS))
