"""
Parse a junit report file into a family of objects
"""
from __future__ import unicode_literals

from typing import TYPE_CHECKING

import os
import sys
import xml.etree.ElementTree as ET
import collections
import uuid

from .case_result import CaseResult
from .render import HTMLReport, JunitLoaderBase
from .textutils import unicode_str

if TYPE_CHECKING:
    from typing import Dict, List, Optional, Union, Any, OrderedDict

NO_CLASSNAME = "no-testclass"
PASSED = CaseResult.PASSED
FAILED = CaseResult.FAILED
SKIPPED = CaseResult.SKIPPED
ABSENT = CaseResult.ABSENT
UNKNOWN = CaseResult.UNKNOWN


def clean_xml_attribute(element: "ET.Element", attribute: str, default: "Optional[str]"=None):
    """
    Get an XML attribute value and ensure it is legal in XML
    :param element:
    :param attribute:
    :param default:
    :return:
    """

    value = element.attrib.get(attribute, default)
    if value:
        value = value.encode("utf-8", errors="replace").decode("utf-8", errors="backslashreplace")
        value = value.replace(u"\ufffd", "?")  # strip out the unicode replacement char

    return value


class ParserError(Exception):
    """
    We had a problem parsing a file
    """
    def __init__(self, message: str):
        super(ParserError, self).__init__(message)


class ToJunitXmlBase(object):
    """
    Base class of all objects that can be serialized to Junit XML
    """
    def tojunit(self) -> "ET.Element":
        """
        Return an Element matching this object
        :return:
        """
        raise NotImplementedError()

    def make_element(self, xmltag: str, text: "Optional[str]"=None, attribs: "Optional[Dict[str, Any]]"=None):
        """
        Create an Element and put text and/or attribs into it
        :param xmltag: tag name
        :param text:
        :param attribs: dict of xml attributes
        :return:
        """
        element = ET.Element(unicode_str(xmltag))
        if text is not None:
            element.text = unicode_str(text)
        if attribs is not None:
            for item in attribs:
                element.set(unicode_str(item), unicode_str(attribs[item]))
        return element


class AnchorBase(object):
    """
    Base class that can generate a unique anchor name.
    """
    def __init__(self):
        self._anchor = None

    def id(self):
        return self.anchor()

    def anchor(self):
        """
        Generate a html anchor name
        :return:
        """
        if not self._anchor:
            self._anchor = str(uuid.uuid4())
        return self._anchor


class Class(AnchorBase):
    """
    A namespace for a test
    """
    name: "Optional[str]" = None
    cases: "list[Case]"
    
    def __init__(self):
        super(Class, self).__init__()
        self.cases = list()


class Property(AnchorBase, ToJunitXmlBase):
    """
    Test Properties
    """
    def __init__(self):
        super(Property, self).__init__()
        self.name: "Optional[str]" = None
        self.value: "Optional[str]" = None

    def tojunit(self):
        """
        Return the xml element for this property
        :return:
        """
        prop = self.make_element("property")
        prop.set(u"name", unicode_str(self.name))
        prop.set(u"value", unicode_str(self.value))
        return prop


class Case(AnchorBase, ToJunitXmlBase):
    """
    Test cases
    """
    failure: "Optional[str]" = None
    failure_msg: "Optional[str]" = None
    skipped: "Optional[str]" = None
    skipped_msg: "Optional[str]" = None
    stderr: "Optional[Union[str,Any]]" = None
    stdout: "Optional[Union[str,Any]]" = None
    duration: float = 0
    name: "Optional[str]" = None
    testclass: "Optional[Class]" = None
    properties: "List[Property]"

    def __init__(self):
        super(Case, self).__init__()
        self.properties = list()

    @property
    def display_suffix(self):
        if self.skipped:
            return "[s]"
        return ""

    def outcome(self) -> CaseResult:
        """
        Return the result of this test case
        :return:
        """
        if self.skipped:
            return CaseResult.SKIPPED
        elif self.failed():
            return CaseResult.FAILED
        return CaseResult.PASSED

    def prefix(self):
        if self.skipped:
            return "[S]"
        if self.failed():
            return "[F]"
        return ""

    def tojunit(self):
        """
        Turn this test case back into junit xml
        :note: this may not be the exact input we loaded
        :return:
        """
        if self.testclass is None or self.testclass.name is None:
            testclass_name = ""
        else:
            testclass_name = self.testclass.name

        testcase = self.make_element("testcase")
        testcase.set(u"name", unicode_str(self.name))
        testcase.set(u"classname", unicode_str(testclass_name))
        testcase.set(u"time", unicode_str(self.duration))

        if self.stderr is not None:
            testcase.append(self.make_element("system-err", self.stderr))
        if self.stdout is not None:
            testcase.append(self.make_element("system-out", self.stdout))

        if self.failure is not None:
            testcase.append(self.make_element(
                "failure", self.failure,
                {
                    "message": self.failure_msg
                }))

        if self.skipped:
            testcase.append(self.make_element(
                "skipped", self.skipped,
                {
                    "message": self.skipped_msg
                }))

        if self.properties:
            props = self.make_element("properties")
            for prop in self.properties:
                props.append(prop.tojunit())
            testcase.append(props)

        return testcase

    def fullname(self):
        """
        Get the full name of a test case
        :return:
        """
        if self.testclass is None or self.testclass.name is None:
            testclass_name = ""
        else:
            testclass_name = self.testclass.name
        return "{} : {}".format(testclass_name, self.name)

    def basename(self):
        """
        Get a short name for this case
        :return:
        """
        if (   self.name is None
            or self.testclass is None
            or self.testclass.name is None
        ):
            return None

        if self.name.startswith(self.testclass.name):
            return self.name[len(self.testclass.name):]
        return self.name

    def failed(self):
        """
        Return True if this test failed
        :return:
        """
        return self.failure is not None


class Suite(AnchorBase, ToJunitXmlBase):
    """
    Contains test cases (usually only one suite per report)
    """
    name: "Optional[str]" = None
    properties: "List[Property]"
    classes: "OrderedDict[str, Class]"
    duration: float = 0
    package: "Optional[str]" = None
    errors: "List[Dict[str, Optional[Union[str,Any]]]]"
    stdout: "Optional[Union[str,Any]]" = None
    stderr: "Optional[Union[str,Any]]" = None

    def __init__(self):
        super(Suite, self).__init__()
        self.classes = collections.OrderedDict()
        self.properties = []
        self.errors = []

    def tojunit(self):
        """
        Return an element for this whole suite and all it's cases
        :return:
        """
        suite = self.make_element("testsuite")
        suite.set(u"name", unicode_str(self.name))
        suite.set(u"time", unicode_str(self.duration))
        if self.properties:
            props = self.make_element("properties")
            for prop in self.properties:
                props.append(prop.tojunit())
            suite.append(props)

        for testcase in self.all():
            suite.append(testcase.tojunit())
        return suite

    def __contains__(self, item: str):
        """
        Return True if the given test classname is part of this test suite
        :param item:
        :return:
        """
        return item in self.classes

    def __getitem__(self, item: str):
        """
        Return the given test class object
        :param item:
        :return:
        """
        return self.classes[item]

    def __setitem__(self, key: str, value: "Class"):
        """
        Add a test class
        :param key:
        :param value:
        :return:
        """
        self.classes[key] = value

    def all(self):
        """
        Return all testcases
        :return:
        """
        tests: "List[Case]" = list()
        for testclass in self.classes:
            tests.extend(self.classes[testclass].cases)
        return tests

    def failed(self):
        """
        Return all the failed testcases
        :return:
        """
        return [test for test in self.all() if test.failed()]

    def skipped(self):
        """
        Return all skipped testcases
        :return:
        """
        return [test for test in self.all() if test.skipped]

    def passed(self):
        """
        Return all the passing testcases
        :return:
        """
        return [test for test in self.all() if not test.failed() and not test.skipped]


class Junit(JunitLoaderBase):
    """
    Parse a single junit xml report
    """
    def __init__(self, filename: "Optional[str]"=None, xmlstring: "Optional[str]"=None):
        """
        Parse the file
        :param filename:
        :return:
        """
        super().__init__()
        self.suites: "List[Suite]" = []
        self.tree: "Optional[Union[ET.ElementTree,ET.Element]]" = None
        self.filename: "Optional[str]" = filename
        if filename == "-":
            # read the xml from stdin
            stdin = sys.stdin.read()
            xmlstring = stdin
            self.filename = None

        if self.filename is not None:
            self.tree = ET.parse(self.filename)
        elif xmlstring is not None:
            self._read(xmlstring)
        else:
            raise ValueError("Missing any filename or xmlstring")
        self.process()


    def __iter__(self):
        return self.suites.__iter__()

    def _read(self, xmlstring: str):
        """
        Populate the junit xml document tree from a string
        :param xmlstring:
        :return:
        """
        self.tree = ET.fromstring(xmlstring)


    def process(self):
        """
        populate the report from the xml
        :return:
        """
        testrun = False
        suites: "Optional[list[ET.Element]]" = None
        root: "ET.Element"
        if isinstance(self.tree, ET.ElementTree):
            root = self.tree.getroot()
        else:
            root = self.tree

        if root.tag == "testrun":
            testrun = True
            root: "ET.Element" = root[0]

        if root.tag == "testsuite":
            suites = [root]

        if root.tag == "testsuites" or testrun:
            suites = [x for x in root]

        if suites is None:
            raise ParserError("could not find test suites in results xml")
        suitecount = 0
        for suite in suites:
            suitecount += 1
            cursuite = Suite()
            self.suites.append(cursuite)
            cursuite.name = clean_xml_attribute(suite, "name", default="suite-" + str(suitecount))
            cursuite.package = clean_xml_attribute(suite, "package")

            cursuite.duration = float(suite.attrib.get("time", '0').replace(',', '') or '0')

            for element in suite:
                if element.tag == "error":
                    # top level error?
                    errtag = {
                        "message": element.attrib.get("message", ""),
                        "type": element.attrib.get("type", ""),
                        "text": element.text
                    }
                    cursuite.errors.append(errtag)
                if element.tag == "system-out":
                    cursuite.stdout = element.text
                if element.tag == "system-err":
                    cursuite.stderr = element.text

                if element.tag == "properties":
                    for prop in element:
                        if prop.tag == "property":
                            newproperty = Property()
                            newproperty.name = prop.attrib["name"]
                            newproperty.value = prop.attrib["value"]
                            cursuite.properties.append(newproperty)

                if element.tag == "testcase":
                    testcase = element

                    if not testcase.attrib.get("classname", None):
                        testcase.attrib["classname"] = NO_CLASSNAME

                    if testcase.attrib["classname"] not in cursuite:
                        testclass = Class()
                        testclass.name = testcase.attrib["classname"]
                        cursuite[testclass.name] = testclass

                    testclass: "Class" = cursuite[testcase.attrib["classname"]]
                    newcase = Case()
                    newcase.name = clean_xml_attribute(testcase, "name")
                    newcase.testclass = testclass
                    newcase.duration = float(testcase.attrib.get("time", '0').replace(',', '') or '0')
                    testclass.cases.append(newcase)

                    # does this test case have any children?
                    for child in testcase:
                        if child.tag == "skipped":
                            newcase.skipped = child.text
                            if "message" in child.attrib:
                                newcase.skipped_msg = child.attrib["message"]
                            if not newcase.skipped:
                               newcase.skipped = "skipped"
                        elif child.tag == "system-out":
                            newcase.stdout = child.text
                        elif child.tag == "system-err":
                            newcase.stderr = child.text
                        elif child.tag == "failure":
                            newcase.failure = child.text
                            if "message" in child.attrib:
                                newcase.failure_msg = child.attrib["message"]
                            if not newcase.failure:
                                newcase.failure = "failed"
                        elif child.tag == "error":
                            newcase.failure = child.text
                            if "message" in child.attrib:
                                newcase.failure_msg = child.attrib["message"]
                            if not newcase.failure:
                                newcase.failure = "error"
                        elif child.tag == "properties":
                            for prop in child:
                                newproperty = Property()
                                newproperty.name = prop.attrib["name"]
                                newproperty.value = prop.attrib["value"]
                                newcase.properties.append(newproperty)

    def html(self, show_toc: bool=True):
        """
        Render the test suite as a HTML report with links to errors first.
        :return:
        """

        doc = HTMLReport(show_toc=show_toc)
        title = "Test Results"
        if self.filename:
            if os.path.exists(self.filename):
                title = os.path.basename(self.filename)
        doc.load(self, title=title)
        return str(doc)
