# Copyright 2019 Katteli Inc.
# TestFlows.com Open-Source Software Testing Framework (http://testflows.com)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import sys
import json
import time
import base64
import threading
import importlib.util

from datetime import datetime
from functools import partial

import testflows.settings as settings
import testflows._core.cli.arg.type as argtype

from testflows._core import __version__
from testflows._core.flags import Flags, SKIP
from testflows._core.testtype import TestType
from testflows._core.cli.arg.common import epilog
from testflows._core.cli.arg.common import HelpFormatter
from testflows._core.cli.arg.handlers.handler import Handler as HandlerBase
from testflows._core.cli.arg.handlers.report.copyright import copyright
from testflows._core.transform.log.pipeline import ResultsLogPipeline
from testflows._core.transform.log.short import format_test, format_result
from testflows._core.utils.timefuncs import localfromtimestamp, strftimedelta
from testflows._core.utils.string import title as make_title
from testflows._core.transform.log.report.totals import Counts
from testflows._core.objects import Requirement

logo = '<img class="logo" src="data:image/png;base64,%(data)s" alt="logo"/>'
testflows = '<span class="testflows-logo"></span> [<span class="logo-test">Test</span><span class="logo-flows">Flows</span>]'
testflows_em = testflows.replace("[", "").replace("]", "")

FailResults = ["Fail", "Error", "Null"]
XoutResults = ["XOK", "XFail", "XError", "XNull"]

template = f"""
<section class="clearfix">%(logo)s%(confidential)s%(copyright)s</section>

---
# Requirements Coverage Report%(title)s
%(body)s

---
Generated by {testflows} Open-Source Test Framework

[<span class="logo-test">Test</span><span class="logo-flows">Flows</span>]: https://testflows.com
[ClickHouse]: https://clickhouse.yandex


<script>
%(script)s
</script>

"""

script = """
window.onload = function(){
    // Toggle requirement description on click
    document.querySelectorAll('.requirement').forEach(
        function(item){
            item.addEventListener('click', function(){
                item.nextElementSibling.classList.toggle('show');
                item.children[0].classList.toggle('active');
            });
        });

    // Toggle test procedure on click
    document.querySelectorAll('.test').forEach(
        function(item){
            item.addEventListener('click', function(){
                item.nextElementSibling.classList.toggle('show');
                item.classList.toggle('active');
            });
        });
}
"""

class Formatter:
    utf_icons = {
        "satisfied": "\u2714",
        "unsatisfied": "\u2718",
        "untested": "\u270E"
    }

    icon_colors = {
        "satisfied": "color-ok",
        "unsatisfied": "color-fail",
        "untested": "color-error"
    }

    def format_logo(self, data):
        if not data["company"].get("logo"):
            return ""
        data = base64.b64encode(data["company"]["logo"]).decode("utf-8")
        return '\n<p>' + logo % {"data": data} + "</p>\n"

    def format_confidential(self, data):
        if not data["company"].get("confidential"):
            return ""
        return f'\n<p class="confidential">Document status - Confidential</p>\n'

    def format_copyright(self, data):
        if not data["company"].get("name"):
            return ""
        return (f'\n<p class="copyright">\n'
            f'{copyright(data["company"]["name"])}\n'
            "</p>\n")

    def format_metadata(self, data):
        metadata = data["metadata"]
        s = (
            "\n\n"
            f"||**Date**||{localfromtimestamp(metadata['date']):%b %d, %Y %-H:%M}||\n"
            f'||**Framework**||'
            f'{testflows} {metadata["version"]}||\n'
        )
        return s + "\n"

    def format_summary(self, data):
        counts = data["counts"]

        def template(value, title, color):
            return (
                f'<div class="c100 p{value} {color} smaller-title">'
                    f'<span>{value}%</span>'
                    f'<span class="title">{title}</span>'
                    '<div class="slice">'
                        '<div class="bar"></div>'
                        '<div class="fill"></div>'
                    '</div>'
                '</div>\n')

        s = "\n## Summary\n"
        if counts.units <= 0:
            s += "No tests"
        else:
            s += '<div class="chart">'
            if counts.satisfied > 0:
                s += template(f"{counts.satisfied / float(counts.units) * 100:.0f}", "Satisfied", "green")
            if counts.unsatisfied > 0:
                s += template(f"{counts.unsatisfied / float(counts.units) * 100:.0f}", "Unsatisfied", "red")
            if counts.untested > 0:
                s += template(f"{counts.untested / float(counts.units) * 100:.0f}", "Untested", "orange")
            s += '</div>\n'
        return s

    def format_statistics(self, data):
        counts = data["counts"]
        result_map = {
            "OK": "Satisfied",
            "Fail": "Unsatisfied",
            "Error": "Untested"
        }
        s = "\n\n## Statistics\n"
        s += "||" + "||".join(
            ["<span></span>", "Units"]
            + [f'<span class="result result-{k.lower()}">{v}</span>' for k, v in result_map.items()]
        ) + "||\n"
        s += "||" + "||".join([f"<center>{i}</center>" for i in ["**Requirements**",
                str(counts.units), str(counts.satisfied),
                str(counts.unsatisfied), str(counts.untested)]]) + "||\n"
        return s + "\n"

    def format_table(self, data):
        reqs = data["requirements"]
        s = "\n\n## Coverage\n"
        for r in reqs.values():
            s += f'\n<section class="requirement"><span class="requirement-inline"><i class="utf-icon {self.icon_colors[r["status"]]}">{self.utf_icons[r["status"]]}</i>{r["requirement"].name}</span></section>'
            description = r["requirement"].description.replace("\\n","\n")
            if description:
                s += f'\n<div markdown="1" class="requirement-description hidden">\n{description}\n</div>'
            for test in r["tests"]:
                result = test["result"]
                cls = result["result_type"].lower()
                s += f'\n<div class="test"><span class="result result-inline result-{cls}">{result["result_type"]}</span><span class="time time-inline">{strftimedelta(result["message_rtime"])}</span>{test["test"]["test_name"]}</div>'
                s += f'\n<div class="test-procedure hidden">\n```testflows\n{test["messages"]}\n```\n</div>'
            if not r["tests"]:
                s += f'\n<div class="no-tests">\n<span class="result-inline">\u270E</span>\nNo tests\n</div>'
            s += "\n"
        return s + "\n"

    def format_title(self, data):
        if data["title"]:
            return "<br>" + make_title(data["title"])
        return ""

    def format(self, data):
        body = ""
        body += self.format_metadata(data)
        body += self.format_summary(data)
        body += self.format_statistics(data)
        body += self.format_table(data)
        return template.strip() % {
            "logo": self.format_logo(data),
            "confidential": self.format_confidential(data),
            "copyright": self.format_copyright(data),
            "body": body,
            "script": script,
            "title": self.format_title(data)
        }

class Counts(object):
    def __init__(self, name, units, satisfied, unsatisfied, untested):
        self.name = name
        self.units = units
        self.satisfied = satisfied
        self.unsatisfied = unsatisfied
        self.untested = untested

    def __bool__(self):
        return self.units > 0

class Handler(HandlerBase):
    @classmethod
    def add_command(cls, commands):
        parser = commands.add_parser("coverage", help="requirements coverage report", epilog=epilog(),
            description="Generate requirements coverage report.",
            formatter_class=HelpFormatter)

        parser.add_argument("requirements", metavar="requirements", type=partial(argtype.path, special=["-"]),
                help="requirements source file, default: '-' (from input log)", nargs="?", default="-")
        parser.add_argument("input", metavar="input", type=argtype.logfile("r", bufsize=1, encoding="utf-8"),
                nargs="?", help="input log, default: stdin", default="-")
        parser.add_argument("output", metavar="output", type=argtype.file("w", bufsize=1, encoding="utf-8"),
                nargs="?", help='output file, default: stdout', default="-")
        parser.add_argument("--show", metavar="status", type=str, nargs="+", help="verification status. Choices: 'satisfied', 'unsatisfied', 'untested'",
            choices=["satisfied", "unsatisfied", "untested"],
            default=["satisfied", "unsatisfied", "untested"])
        parser.add_argument("--input-link", metavar="attribute",
            help="attribute that is used as a link to the input log, default: job.url",
            type=str, default="job.url")
        parser.add_argument("--format", metavar="type", type=str,
            help="output format, default: md (Markdown)", choices=["md"], default="md")
        parser.add_argument("--copyright", metavar="name", help="add copyright notice", type=str)
        parser.add_argument("--confidential", help="mark as confidential", action="store_true")
        parser.add_argument("--logo", metavar="path", type=argtype.file("rb"),
                help='use logo image (.png)')
        parser.add_argument("--title", metavar="name", help="custom title", type=str)
        parser.add_argument("--only", metavar="name", type=str, default=[], nargs="+",
                help=("name of one or more specifications for which to generate coverage report"
                    ", default: include all specifications. Only a unique part of the name can be specified."
            ))

        parser.set_defaults(func=cls())

    def get_attribute(self, result, name, default=None):
        tests = list(result["tests"].values())

        if not tests:
            return default

        test = tests[0]["test"]
        for attr in test["attributes"]:
            if attr["attribute_name"] == name:
                return attr["attribute_value"]

        return default

    def table(self, results):
        table = {
            "header": ["Requirement", "Tests"],
            "rows": [],
        }

        return table

    def metadata(self, results):
        return {
            "date": time.time(),
            "version": __version__,
        }

    def requirements(self, spec_names, path, results):
        _requirements = {}
        _specs = []

        if path == "-":
            for spec in results["specifications"]:
                if spec_names:
                    matched = False
                    for name in spec_names:
                        if name in spec["specification_name"]:
                            matched = True
                            break
                    if not matched:
                        continue
                _specs.append(spec)
                for req in spec["specification_requirements"]:
                    _requirements[req["name"]] = {"requirement": Requirement(**req), "tests": []}
        else:
            spec = importlib.util.spec_from_file_location("requirements", path)
            module = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(module)

            for name, value in vars(module).items():
                if not isinstance(value, Requirement):
                    continue
                _requirements[value.name] = {"requirement": value, "tests": []}

        return (_specs, _requirements)

    def add_test_messages(self, test, idx, tests, tests_by_parent, tests_by_id):
        started = test["test"]["message_time"]
        ended = test["result"]["message_time"]

        messages = [format_test(test["test"], "", tests_by_parent, tests_by_id, no_colors=True)]

        if getattr(TestType, test["test"]["test_type"]) > TestType.Test:
            for t in tests[idx + 1:]:
                flags = Flags(t["test"]["test_flags"])
                if flags & SKIP and settings.show_skipped is False:
                    continue
                if t["test"]["message_time"] > ended:
                    break
                if getattr(TestType, t["test"]["test_type"]) >= TestType.Test \
                        and t["test"]["test_id"].startswith(test["test"]["test_id"]):
                    messages.append(format_test(t["test"], "", tests_by_parent, tests_by_id, no_colors=True))
                    messages.append(format_result(t["result"], no_colors=True))
        else:
            for t in tests[idx + 1:]:
                flags = Flags(t["test"]["test_flags"])
                if flags & SKIP and settings.show_skipped is False:
                    continue
                if t["test"]["message_time"] > ended:
                    break
                if t["test"]["test_id"].startswith(test["test"]["test_id"]):
                    messages.append(format_test(t["test"], "", tests_by_parent, tests_by_id, no_colors=True))
                    messages.append(format_result(t["result"], no_colors=True))

        messages.append(format_result(test["result"], no_colors=True))

        test["messages"] = "".join(messages)
        return test

    def add_tests(self, requirements, results):
        tests = list(results["tests"].values())
        for i, test in enumerate(tests):
            flags = Flags(test["test"]["test_flags"])

            if flags & SKIP and settings.show_skipped is False:
                continue
            result = test["result"]

            for requirement in test["test"]["requirements"]:
                if requirement["requirement_name"] in requirements:
                    requirements[requirement["requirement_name"]]["tests"].append(self.add_test_messages(test, i, tests, results["tests_by_parent"], results["tests_by_id"]))

        return requirements

    def counts(self, requirements):
        counts = Counts("requirements", *([0] * 4))

        for req in requirements.values():
            counts.units += 1
            tests = req["tests"]
            if not tests:
                counts.untested += 1
                req["status"] = "untested"
            else:
                satisfied = True
                for test in tests:
                    result = test["result"]
                    if result["result_type"] != "OK":
                        satisfied = False
                if satisfied:
                    counts.satisfied += 1
                    req["status"] = "satisfied"
                else:
                    counts.unsatisfied += 1
                    req["status"] = "unsatisfied"
        return counts

    def company(self, args):
        d = {}
        if args.copyright:
            d["name"] = args.copyright
        if args.confidential:
            d["confidential"] = True
        if args.logo:
            d["logo"] = args.logo.read()
        return d

    def data(self, source, results, args):
        d = dict()
        specs, requirements = self.requirements(args.only, source, results)
        # if custom title was not specified generate a title
        # that include all specification names
        title = args.title
        if title is None and specs:
            title = "<br>".join([spec["specification_name"] for spec in specs])
        d["title"] = title
        d["requirements"] = self.add_tests(requirements, results)
        d["metadata"] = self.metadata(results)
        d["counts"] = self.counts(d["requirements"])
        d["company"] = self.company(args)
        counts = d["counts"]
        return d

    def generate(self, formatter, results, args):
        output = args.output
        output.write(
            formatter.format(self.data(args.requirements, results, args))
        )
        output.write("\n")

    def handle(self, args):
        results = {}
        formatter = Formatter()
        ResultsLogPipeline(args.input, results).run()
        self.generate(formatter, results, args)
