'''
Created on Mar 26, 2016

@author: nicolas
'''

import time
from decimal import Decimal
from functools import reduce
from typing import Union, List, Generator, Iterable, Optional
import datetime
import calendar

from lemoncheesecake.consts import LOG_LEVEL_ERROR, LOG_LEVEL_WARN
from lemoncheesecake.helpers.time import humanize_duration
from lemoncheesecake.testtree import BaseTest, BaseSuite, flatten_tests, flatten_suites, find_test, find_suite, \
    normalize_node_hierarchy, TreeNodeHierarchy
from lemoncheesecake.suite.core import Test

_TEST_STATUSES = "passed", "failed", "skipped", "disabled"
_DEFAULT_REPORT_TITLE = "Test Report"


# NB: it would be nicer to use:
# datetime.isoformat(sep=' ', timespec='milliseconds')
# unfortunately, the timespec argument is only available since Python 3.6
def format_time_as_iso8601(ts):
    # type: (float) -> str
    """
    Serialize time as ISO8601, such as: "2019-05-04T22:57:08.399Z"
    """
    ts = round(ts, 3)  # round the timestamp to milliseconds
    result = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(ts))  # builds datetime with second precision
    result += ".%03d" % (Decimal(repr(ts)) % 1 * 1000)  # adds millisecond precision
    result += "Z"  # declares as UTC
    return result


def parse_iso8601_time(s):
    # type: (str) -> float
    """
    Parse time as generated by format_time_as_iso8601
    """
    dt = datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%S.%fZ")
    return calendar.timegm(dt.utctimetuple()) + float(dt.microsecond) / 1000000


def _get_duration(start_time, end_time):
    # type: (Optional[float], Optional[float]) -> Optional[float]
    if start_time is not None and end_time is not None:
        return end_time - start_time
    else:
        return None


class Log(object):
    def __init__(self, level, message, ts):
        # type: (str, str, float) -> None
        self.level = level
        self.message = message
        self.time = ts


class Check(object):
    def __init__(self, description, is_successful, details, ts):
        # type: (str, bool, Optional[str], float) -> None
        self.description = description
        self.is_successful = is_successful
        self.details = details
        self.time = ts


class Attachment(object):
    def __init__(self, description, filename, as_image, ts):
        # type: (str, str, bool, float) -> None
        self.description = description
        self.filename = filename
        self.as_image = as_image
        self.time = ts


class Url(object):
    def __init__(self, description, url, ts):
        # type: (str, str, float) -> None
        self.description = description
        self.url = url
        self.time = ts


class Step(object):
    def __init__(self, description, detached=False):
        # type: (str, bool) -> None
        self.description = description
        self._detached = detached  # this attribute is runtime only is not intended to be serialized
        self.entries = []  # type: List[Union[Log, Check, Attachment, Url]]
        self.start_time = None  # type: Optional[float]
        self.end_time = None  # type: Optional[float]

    @staticmethod
    def _is_entry_successful(entry):
        if isinstance(entry, Check):
            return entry.is_successful
        elif isinstance(entry, Log):
            return entry.level != LOG_LEVEL_ERROR
        return True

    def is_successful(self):
        # type: () -> bool
        return all(map(Step._is_entry_successful, self.entries))

    @property
    def duration(self):
        # type: () -> Optional[float]
        return _get_duration(self.start_time, self.end_time)


class Result(object):
    def __init__(self):
        # please note that this attribute is also defined in TestResult through BaseTest,
        # one will override the other
        self.parent_suite = None  # type: Optional[SuiteResult]
        self.type = None  # type: Optional[str]
        self.steps = []  # type: List[Step]
        self.start_time = None  # type: Optional[float]
        self.end_time = None  # type: Optional[float]
        self.status = None  # type: Optional[str]
        self.status_details = None  # type: Optional[str]

    def is_successful(self):
        # type: () -> bool
        if self.status:  # test is finished
            return self.status in ("passed", "disabled")
        else:  # check if the test is successful "so far"
            return all(step.is_successful() for step in self.steps)

    @property
    def duration(self):
        # type: () -> Optional[float]
        return _get_duration(self.start_time, self.end_time)

    def is_empty(self):
        # type: () -> bool
        return len(self.steps) == 0


class TestResult(BaseTest, Result):
    def __init__(self, name, description):
        BaseTest.__init__(self, name, description)
        Result.__init__(self)
        # non-serialized attributes (only set in-memory during test execution):
        self.rank = 0

    @classmethod
    def from_test(cls, test):
        # type: (Test) -> TestResult
        test_data = cls(test.name, test.description)
        test_data.tags.extend(test.tags)
        test_data.properties.update(test.properties)
        test_data.links.extend(test.links)
        test_data.rank = test.rank
        return test_data


class SuiteResult(BaseSuite):
    def __init__(self, name, description):
        BaseSuite.__init__(self, name, description)
        self.start_time = None  # type: Optional[float]
        self.end_time = None  # type: Optional[float]
        self._suite_setup = None  # type: Optional[Result]
        self._suite_teardown = None  # type: Optional[Result]
        # non-serialized attributes (only set in-memory during test execution)
        self.rank = 0

    @property
    def suite_setup(self):
        return self._suite_setup
    
    @suite_setup.setter
    def suite_setup(self, setup):
        if setup:
            setup.parent_suite = self
            setup.type = "suite_setup"
        self._suite_setup = setup

    @property
    def suite_teardown(self):
        return self._suite_teardown

    @suite_teardown.setter
    def suite_teardown(self, teardown):
        if teardown:
            teardown.parent_suite = self
            teardown.type = "suite_teardown"
        self._suite_teardown = teardown

    @property
    def duration(self):
        # type: () -> Optional[float]
        return reduce(
            lambda x, y: x + y,
            # result.duration is None if the corresponding result is in progress
            [result.duration or 0 for result in flatten_results_from_suites([self])],
            0
        )

    def get_tests(self):
        # type: () -> List[TestResult]
        tests = super(SuiteResult, self).get_tests()
        return sorted(tests, key=lambda t: t.rank)

    def get_suites(self, include_empty_suites=False):
        # type: (bool) -> List[SuiteResult]
        suites = super(SuiteResult, self).get_suites(include_empty_suites)
        return sorted(suites, key=lambda s: s.rank)


class _Stats(object):
    def __init__(self):
        self.tests = 0
        self.test_statuses = {s: 0 for s in _TEST_STATUSES}
        self.errors = 0
        self.checks = 0
        self.check_successes = 0
        self.check_failures = 0
        self.error_logs = 0
        self.warning_logs = 0
        self.duration = None
        self.duration_cumulative = 0

    @property
    def enabled_tests(self):
        return self.tests - self.test_statuses["disabled"]

    @property
    def successful_tests_percentage(self):
        return (float(self.test_statuses["passed"]) / self.enabled_tests * 100) if self.enabled_tests else 0

    @property
    def duration_cumulative_description(self):
        description = humanize_duration(self.duration_cumulative)
        if self.duration:
            description += " (parallelization speedup factor is %.1f)" % (float(self.duration_cumulative) / self.duration)
        return description


def _update_stats_from_results(stats, results):
    for result in results:
        if result.duration is not None:
            stats.duration_cumulative += result.duration
        for step in result.steps:
            for entry in step.entries:
                if isinstance(entry, Check):
                    stats.checks += 1
                    if entry.is_successful is True:
                        stats.check_successes += 1
                    elif entry.is_successful is False:
                        stats.check_failures += 1
                if isinstance(entry, Log):
                    if entry.level == LOG_LEVEL_WARN:
                        stats.warning_logs += 1
                    elif entry.level == LOG_LEVEL_ERROR:
                        stats.error_logs += 1


def _update_stats_from_tests(stats, tests):
    stats.tests = len(tests)

    for test in tests:
        if test.status:
            stats.test_statuses[test.status] += 1


def get_stats_from_suites(suites, parallelized):
    stats = _Stats()

    results = list(flatten_results_from_suites(suites))

    if not parallelized:
        stats.duration = results[-1].end_time - results[0].start_time

    _update_stats_from_results(stats, results)
    _update_stats_from_tests(stats, list(flatten_tests(suites)))

    return stats


class ReportLocation(object):
    _TEST_SESSION_SETUP = 0
    _TEST_SESSION_TEARDOWN = 1
    _SUITE_SETUP = 2
    _SUITE_TEARDOWN = 3
    _TEST = 4

    def __init__(self, node_type, node_hierarchy=None):
        self.node_type = node_type
        self.node_hierarchy = node_hierarchy

    @classmethod
    def in_test_session_setup(cls):
        # type: () -> ReportLocation
        return cls(cls._TEST_SESSION_SETUP)

    @classmethod
    def in_test_session_teardown(cls):
        # type: () -> ReportLocation
        return cls(cls._TEST_SESSION_TEARDOWN)

    @classmethod
    def in_suite_setup(cls, suite):
        # type: (TreeNodeHierarchy) -> ReportLocation
        return cls(cls._SUITE_SETUP, normalize_node_hierarchy(suite))

    @classmethod
    def in_suite_teardown(cls, suite):
        # type: (TreeNodeHierarchy) -> ReportLocation
        return cls(cls._SUITE_TEARDOWN, normalize_node_hierarchy(suite))

    @classmethod
    def in_test(cls, test):
        # type: (TreeNodeHierarchy) -> ReportLocation
        return cls(cls._TEST, normalize_node_hierarchy(test))

    def get(self, report):
        # type: (Report) -> Union[Result, SuiteResult, TestResult, None]
        if self.node_type == self._TEST_SESSION_SETUP:
            return report.test_session_setup
        elif self.node_type == self._TEST_SESSION_TEARDOWN:
            return report.test_session_teardown
        elif self.node_type == self._SUITE_SETUP:
            suite = report.get_suite(self.node_hierarchy)
            return suite.suite_setup
        elif self.node_type == self._SUITE_TEARDOWN:
            suite = report.get_suite(self.node_hierarchy)
            return suite.suite_teardown
        elif self.node_type == self._TEST:
            return report.get_test(self.node_hierarchy)
        else:
            raise Exception("Unknown self type %s" % self.node_type)

    def __eq__(self, other):
        return all((
            isinstance(other, ReportLocation),
            self.node_type == other.node_type,
            self.node_hierarchy == other.node_hierarchy
        ))

    def __hash__(self):
        return hash((self.node_type, self.node_hierarchy))

    def __str__(self):
        ret = ""
        if self.node_hierarchy:
            ret += ".".join(self.node_hierarchy) + " "
        ret += ("session setup", "session teardown", "suite setup", "suite teardown", "test")[self.node_type]
        return "<%s>" % ret


class Report(object):
    def __init__(self):
        self.info = []
        self._test_session_setup = None  # type: Optional[Result]
        self._test_session_teardown = None  # type: Optional[Result]
        self._suites = []  # type: List[SuiteResult]
        self.start_time = None  # type: Optional[float]
        self.end_time = None  # type: Optional[float]
        self.report_generation_time = None  # type: Optional[float]
        self.title = _DEFAULT_REPORT_TITLE
        self.nb_threads = 1

    @property
    def test_session_setup(self):
        return self._test_session_setup

    @test_session_setup.setter
    def test_session_setup(self, setup):
        if setup:
            setup.type = "test_session_setup"
        self._test_session_setup = setup

    @property
    def test_session_teardown(self):
        return self._test_session_teardown

    @test_session_teardown.setter
    def test_session_teardown(self, teardown):
        if teardown:
            teardown.type = "test_session_teardown"
        self._test_session_teardown = teardown

    @property
    def duration(self):
        # type: () -> Optional[float]
        return _get_duration(self.start_time, self.end_time)

    @property
    def nb_tests(self):
        # type: () -> int
        return len(list(self.all_tests()))

    @property
    def parallelized(self):
        # type: () -> bool
        return self.nb_threads > 1 and self.nb_tests > 1

    def add_info(self, name, value):
        # type: (str, str) -> None
        self.info.append([name, value])
    
    def add_suite(self, suite):
        # type: (SuiteResult) -> None
        self._suites.append(suite)
    
    def get_suites(self):
        # type: () -> List[SuiteResult]
        return sorted(self._suites, key=lambda s: s.rank)

    def get_suite(self, hierarchy):
        # type: (List[str]) -> SuiteResult
        return find_suite(self._suites, hierarchy)

    def get_test(self, hierarchy):
        # type: (List[str]) -> TestResult
        return find_test(self._suites, hierarchy)

    def get(self, location):
        # type: (ReportLocation) -> Union[Result, SuiteResult, TestResult, None]
        return location.get(self)

    def is_successful(self):
        # type: () -> bool
        return all(test.status in ("passed", "disabled") for test in self.all_tests())

    def stats(self):
        # type: () -> _Stats
        stats = _Stats()

        if self.end_time is not None:
            stats.duration = self.end_time - self.start_time
        _update_stats_from_results(stats, self.all_results())
        _update_stats_from_tests(stats, list(self.all_tests()))

        return stats

    def all_suites(self):
        # type: () -> Generator[SuiteResult]
        return flatten_suites(self._suites)

    def all_tests(self):
        # type: () -> Generator[TestResult]
        return flatten_tests(self._suites)

    def all_results(self):
        # type: () -> Generator[Result]
        if self.test_session_setup:
            yield self.test_session_setup
        for result in flatten_results_from_suites(self.get_suites()):
            yield result
        if self.test_session_teardown:
            yield self.test_session_teardown


def flatten_steps(suites):
    # type: (Iterable[SuiteResult]) -> Generator[Step]
    for test in flatten_tests(suites):
        for step in test.steps:
            yield step


def flatten_results_from_suites(suites):
    # type: (Iterable[SuiteResult]) -> Generator[Result]
    for suite in flatten_suites(suites):
        if suite.suite_setup:
            yield suite.suite_setup
        for test in suite.get_tests():
            yield test
        if suite.suite_teardown:
            yield suite.suite_teardown
