import operator
import os
import shutil
import sys
import textwrap
import tempfile
from unittest import skipIf, TestCase


def isTwistedInstalled():
    try:
        __import__("twisted")
    except ImportError:
        return False
    else:
        return True


class _WritesPythonModules(TestCase):
    """
    A helper that enables generating Python module test fixtures.
    """

    def setUp(self):
        super(_WritesPythonModules, self).setUp()

        from twisted.python.modules import getModule, PythonPath
        from twisted.python.filepath import FilePath

        self.getModule = getModule
        self.PythonPath = PythonPath
        self.FilePath = FilePath

        self.originalSysModules = set(sys.modules.keys())
        self.savedSysPath = sys.path[:]

        self.pathDir = tempfile.mkdtemp()
        self.makeImportable(self.pathDir)

    def tearDown(self):
        super(_WritesPythonModules, self).tearDown()

        sys.path[:] = self.savedSysPath
        modulesToDelete = sys.modules.keys() - self.originalSysModules
        for module in modulesToDelete:
            del sys.modules[module]

        shutil.rmtree(self.pathDir)

    def makeImportable(self, path):
        sys.path.append(path)

    def writeSourceInto(self, source, path, moduleName):
        directory = self.FilePath(path)

        module = directory.child(moduleName)
        # FilePath always opens a file in binary mode - but that will
        # break on Python 3
        with open(module.path, "w") as f:
            f.write(textwrap.dedent(source))

        return self.PythonPath([directory.path])

    def makeModule(self, source, path, moduleName):
        pythonModuleName, _ = os.path.splitext(moduleName)
        return self.writeSourceInto(source, path, moduleName)[pythonModuleName]

    def attributesAsDict(self, hasIterAttributes):
        return {attr.name: attr for attr in hasIterAttributes.iterAttributes()}

    def loadModuleAsDict(self, module):
        module.load()
        return self.attributesAsDict(module)

    def makeModuleAsDict(self, source, path, name):
        return self.loadModuleAsDict(self.makeModule(source, path, name))


@skipIf(not isTwistedInstalled(), "Twisted is not installed.")
class OriginalLocationTests(_WritesPythonModules):
    """
    Tests that L{isOriginalLocation} detects when a
    L{PythonAttribute}'s FQPN refers to an object inside the module
    where it was defined.

    For example: A L{twisted.python.modules.PythonAttribute} with a
    name of 'foo.bar' that refers to a 'bar' object defined in module
    'baz' does *not* refer to bar's original location, while a
    L{PythonAttribute} with a name of 'baz.bar' does.

    """

    def setUp(self):
        super(OriginalLocationTests, self).setUp()
        from .._discover import isOriginalLocation

        self.isOriginalLocation = isOriginalLocation

    def test_failsWithNoModule(self):
        """
        L{isOriginalLocation} returns False when the attribute refers to an
        object whose source module cannot be determined.
        """
        source = """\
        class Fake(object):
            pass
        hasEmptyModule = Fake()
        hasEmptyModule.__module__ = None
        """

        moduleDict = self.makeModuleAsDict(source, self.pathDir, "empty_module_attr.py")

        self.assertFalse(
            self.isOriginalLocation(moduleDict["empty_module_attr.hasEmptyModule"])
        )

    def test_failsWithDifferentModule(self):
        """
        L{isOriginalLocation} returns False when the attribute refers to
        an object outside of the module where that object was defined.
        """
        originalSource = """\
        class ImportThisClass(object):
            pass
        importThisObject = ImportThisClass()
        importThisNestingObject = ImportThisClass()
        importThisNestingObject.nestedObject = ImportThisClass()
        """

        importingSource = """\
        from original import (ImportThisClass,
                              importThisObject,
                              importThisNestingObject)
        """

        self.makeModule(originalSource, self.pathDir, "original.py")
        importingDict = self.makeModuleAsDict(
            importingSource, self.pathDir, "importing.py"
        )
        self.assertFalse(
            self.isOriginalLocation(importingDict["importing.ImportThisClass"])
        )
        self.assertFalse(
            self.isOriginalLocation(importingDict["importing.importThisObject"])
        )

        nestingObject = importingDict["importing.importThisNestingObject"]
        nestingObjectDict = self.attributesAsDict(nestingObject)
        nestedObject = nestingObjectDict[
            "importing.importThisNestingObject.nestedObject"
        ]

        self.assertFalse(self.isOriginalLocation(nestedObject))

    def test_succeedsWithSameModule(self):
        """
        L{isOriginalLocation} returns True when the attribute refers to an
        object inside the module where that object was defined.
        """
        mSource = textwrap.dedent(
            """
        class ThisClassWasDefinedHere(object):
            pass
        anObject = ThisClassWasDefinedHere()
        aNestingObject = ThisClassWasDefinedHere()
        aNestingObject.nestedObject = ThisClassWasDefinedHere()
        """
        )
        mDict = self.makeModuleAsDict(mSource, self.pathDir, "m.py")
        self.assertTrue(self.isOriginalLocation(mDict["m.ThisClassWasDefinedHere"]))
        self.assertTrue(self.isOriginalLocation(mDict["m.aNestingObject"]))

        nestingObject = mDict["m.aNestingObject"]
        nestingObjectDict = self.attributesAsDict(nestingObject)
        nestedObject = nestingObjectDict["m.aNestingObject.nestedObject"]

        self.assertTrue(self.isOriginalLocation(nestedObject))


@skipIf(not isTwistedInstalled(), "Twisted is not installed.")
class FindMachinesViaWrapperTests(_WritesPythonModules):
    """
    L{findMachinesViaWrapper} recursively yields FQPN,
    L{MethodicalMachine} pairs in and under a given
    L{twisted.python.modules.PythonModule} or
    L{twisted.python.modules.PythonAttribute}.
    """

    def setUp(self):
        super(FindMachinesViaWrapperTests, self).setUp()
        from .._discover import findMachinesViaWrapper

        self.findMachinesViaWrapper = findMachinesViaWrapper

    def test_yieldsMachine(self):
        """
        When given a L{twisted.python.modules.PythonAttribute} that refers
        directly to a L{MethodicalMachine}, L{findMachinesViaWrapper}
        yields that machine and its FQPN.
        """
        source = """\
        from automat import MethodicalMachine

        rootMachine = MethodicalMachine()
        """

        moduleDict = self.makeModuleAsDict(source, self.pathDir, "root.py")
        rootMachine = moduleDict["root.rootMachine"]
        self.assertIn(
            ("root.rootMachine", rootMachine.load()),
            list(self.findMachinesViaWrapper(rootMachine)),
        )

    def test_yieldsTypeMachine(self) -> None:
        """
        When given a L{twisted.python.modules.PythonAttribute} that refers
        directly to a L{TypeMachine}, L{findMachinesViaWrapper} yields that
        machine and its FQPN.
        """
        source = """\
        from automat import TypeMachineBuilder
        from typing import Protocol, Callable
        class P(Protocol):
            def method(self) -> None: ...
        class C:...
        def buildBuilder() -> Callable[[C], P]:
            builder = TypeMachineBuilder(P, C)
            return builder.build()
        rootMachine = buildBuilder()
        """

        moduleDict = self.makeModuleAsDict(source, self.pathDir, "root.py")
        rootMachine = moduleDict["root.rootMachine"]
        self.assertIn(
            ("root.rootMachine", rootMachine.load()),
            list(self.findMachinesViaWrapper(rootMachine)),
        )

    def test_yieldsMachineInClass(self):
        """
        When given a L{twisted.python.modules.PythonAttribute} that refers
        to a class that contains a L{MethodicalMachine} as a class
        variable, L{findMachinesViaWrapper} yields that machine and
        its FQPN.
        """
        source = """\
        from automat import MethodicalMachine

        class PythonClass(object):
            _classMachine = MethodicalMachine()
        """
        moduleDict = self.makeModuleAsDict(source, self.pathDir, "clsmod.py")
        PythonClass = moduleDict["clsmod.PythonClass"]
        self.assertIn(
            ("clsmod.PythonClass._classMachine", PythonClass.load()._classMachine),
            list(self.findMachinesViaWrapper(PythonClass)),
        )

    def test_yieldsMachineInNestedClass(self):
        """
        When given a L{twisted.python.modules.PythonAttribute} that refers
        to a nested class that contains a L{MethodicalMachine} as a
        class variable, L{findMachinesViaWrapper} yields that machine
        and its FQPN.
        """
        source = """\
        from automat import MethodicalMachine

        class PythonClass(object):
            class NestedClass(object):
                _classMachine = MethodicalMachine()
        """
        moduleDict = self.makeModuleAsDict(source, self.pathDir, "nestedcls.py")

        PythonClass = moduleDict["nestedcls.PythonClass"]
        self.assertIn(
            (
                "nestedcls.PythonClass.NestedClass._classMachine",
                PythonClass.load().NestedClass._classMachine,
            ),
            list(self.findMachinesViaWrapper(PythonClass)),
        )

    def test_yieldsMachineInModule(self):
        """
        When given a L{twisted.python.modules.PythonModule} that refers to
        a module that contains a L{MethodicalMachine},
        L{findMachinesViaWrapper} yields that machine and its FQPN.
        """
        source = """\
        from automat import MethodicalMachine

        rootMachine = MethodicalMachine()
        """
        module = self.makeModule(source, self.pathDir, "root.py")
        rootMachine = self.loadModuleAsDict(module)["root.rootMachine"].load()
        self.assertIn(
            ("root.rootMachine", rootMachine), list(self.findMachinesViaWrapper(module))
        )

    def test_yieldsMachineInClassInModule(self):
        """
        When given a L{twisted.python.modules.PythonModule} that refers to
        the original module of a class containing a
        L{MethodicalMachine}, L{findMachinesViaWrapper} yields that
        machine and its FQPN.
        """
        source = """\
        from automat import MethodicalMachine

        class PythonClass(object):
            _classMachine = MethodicalMachine()
        """
        module = self.makeModule(source, self.pathDir, "clsmod.py")
        PythonClass = self.loadModuleAsDict(module)["clsmod.PythonClass"].load()
        self.assertIn(
            ("clsmod.PythonClass._classMachine", PythonClass._classMachine),
            list(self.findMachinesViaWrapper(module)),
        )

    def test_yieldsMachineInNestedClassInModule(self):
        """
        When given a L{twisted.python.modules.PythonModule} that refers to
        the original module of a nested class containing a
        L{MethodicalMachine}, L{findMachinesViaWrapper} yields that
        machine and its FQPN.
        """
        source = """\
        from automat import MethodicalMachine

        class PythonClass(object):
            class NestedClass(object):
                _classMachine = MethodicalMachine()
        """
        module = self.makeModule(source, self.pathDir, "nestedcls.py")
        PythonClass = self.loadModuleAsDict(module)["nestedcls.PythonClass"].load()

        self.assertIn(
            (
                "nestedcls.PythonClass.NestedClass._classMachine",
                PythonClass.NestedClass._classMachine,
            ),
            list(self.findMachinesViaWrapper(module)),
        )

    def test_ignoresImportedClass(self):
        """
        When given a L{twisted.python.modules.PythonAttribute} that refers
        to a class imported from another module, any
        L{MethodicalMachine}s on that class are ignored.

        This behavior ensures that a machine is only discovered on a
        class when visiting the module where that class was defined.
        """
        originalSource = """
        from automat import MethodicalMachine

        class PythonClass(object):
            _classMachine = MethodicalMachine()
        """

        importingSource = """
        from original import PythonClass
        """

        self.makeModule(originalSource, self.pathDir, "original.py")
        importingModule = self.makeModule(importingSource, self.pathDir, "importing.py")

        self.assertFalse(list(self.findMachinesViaWrapper(importingModule)))

    def test_descendsIntoPackages(self):
        """
        L{findMachinesViaWrapper} descends into packages to discover
        machines.
        """
        pythonPath = self.PythonPath([self.pathDir])
        package = self.FilePath(self.pathDir).child("test_package")
        package.makedirs()
        package.child("__init__.py").touch()

        source = """
        from automat import MethodicalMachine


        class PythonClass(object):
            _classMachine = MethodicalMachine()


        rootMachine = MethodicalMachine()
        """
        self.makeModule(source, package.path, "module.py")

        test_package = pythonPath["test_package"]
        machines = sorted(
            self.findMachinesViaWrapper(test_package), key=operator.itemgetter(0)
        )

        moduleDict = self.loadModuleAsDict(test_package["module"])
        rootMachine = moduleDict["test_package.module.rootMachine"].load()
        PythonClass = moduleDict["test_package.module.PythonClass"].load()

        expectedMachines = sorted(
            [
                ("test_package.module.rootMachine", rootMachine),
                (
                    "test_package.module.PythonClass._classMachine",
                    PythonClass._classMachine,
                ),
            ],
            key=operator.itemgetter(0),
        )

        self.assertEqual(expectedMachines, machines)

    def test_infiniteLoop(self):
        """
        L{findMachinesViaWrapper} ignores infinite loops.

        Note this test can't fail - it can only run forever!
        """
        source = """
        class InfiniteLoop(object):
            pass

        InfiniteLoop.loop = InfiniteLoop
        """
        module = self.makeModule(source, self.pathDir, "loop.py")
        self.assertFalse(list(self.findMachinesViaWrapper(module)))


@skipIf(not isTwistedInstalled(), "Twisted is not installed.")
class WrapFQPNTests(TestCase):
    """
    Tests that ensure L{wrapFQPN} loads the
    L{twisted.python.modules.PythonModule} or
    L{twisted.python.modules.PythonAttribute} for a given FQPN.
    """

    def setUp(self):
        from twisted.python.modules import PythonModule, PythonAttribute
        from .._discover import wrapFQPN, InvalidFQPN, NoModule, NoObject

        self.PythonModule = PythonModule
        self.PythonAttribute = PythonAttribute
        self.wrapFQPN = wrapFQPN
        self.InvalidFQPN = InvalidFQPN
        self.NoModule = NoModule
        self.NoObject = NoObject

    def assertModuleWrapperRefersTo(self, moduleWrapper, module):
        """
        Assert that a L{twisted.python.modules.PythonModule} refers to a
        particular Python module.
        """
        self.assertIsInstance(moduleWrapper, self.PythonModule)
        self.assertEqual(moduleWrapper.name, module.__name__)
        self.assertIs(moduleWrapper.load(), module)

    def assertAttributeWrapperRefersTo(self, attributeWrapper, fqpn, obj):
        """
        Assert that a L{twisted.python.modules.PythonAttribute} refers to a
        particular Python object.
        """
        self.assertIsInstance(attributeWrapper, self.PythonAttribute)
        self.assertEqual(attributeWrapper.name, fqpn)
        self.assertIs(attributeWrapper.load(), obj)

    def test_failsWithEmptyFQPN(self):
        """
        L{wrapFQPN} raises L{InvalidFQPN} when given an empty string.
        """
        with self.assertRaises(self.InvalidFQPN):
            self.wrapFQPN("")

    def test_failsWithBadDotting(self):
        """ "
        L{wrapFQPN} raises L{InvalidFQPN} when given a badly-dotted
        FQPN.  (e.g., x..y).
        """
        for bad in (".fails", "fails.", "this..fails"):
            with self.assertRaises(self.InvalidFQPN):
                self.wrapFQPN(bad)

    def test_singleModule(self):
        """
        L{wrapFQPN} returns a L{twisted.python.modules.PythonModule}
        referring to the single module a dotless FQPN describes.
        """
        import os

        moduleWrapper = self.wrapFQPN("os")

        self.assertIsInstance(moduleWrapper, self.PythonModule)
        self.assertIs(moduleWrapper.load(), os)

    def test_failsWithMissingSingleModuleOrPackage(self):
        """
        L{wrapFQPN} raises L{NoModule} when given a dotless FQPN that does
        not refer to a module or package.
        """
        with self.assertRaises(self.NoModule):
            self.wrapFQPN("this is not an acceptable name!")

    def test_singlePackage(self):
        """
        L{wrapFQPN} returns a L{twisted.python.modules.PythonModule}
        referring to the single package a dotless FQPN describes.
        """
        import xml

        self.assertModuleWrapperRefersTo(self.wrapFQPN("xml"), xml)

    def test_multiplePackages(self):
        """
        L{wrapFQPN} returns a L{twisted.python.modules.PythonModule}
        referring to the deepest package described by dotted FQPN.
        """
        import xml.etree

        self.assertModuleWrapperRefersTo(self.wrapFQPN("xml.etree"), xml.etree)

    def test_multiplePackagesFinalModule(self):
        """
        L{wrapFQPN} returns a L{twisted.python.modules.PythonModule}
        referring to the deepest module described by dotted FQPN.
        """
        import xml.etree.ElementTree

        self.assertModuleWrapperRefersTo(
            self.wrapFQPN("xml.etree.ElementTree"), xml.etree.ElementTree
        )

    def test_singleModuleObject(self):
        """
        L{wrapFQPN} returns a L{twisted.python.modules.PythonAttribute}
        referring to the deepest object an FQPN names, traversing one module.
        """
        import os

        self.assertAttributeWrapperRefersTo(
            self.wrapFQPN("os.path"), "os.path", os.path
        )

    def test_multiplePackagesObject(self):
        """
        L{wrapFQPN} returns a L{twisted.python.modules.PythonAttribute}
        referring to the deepest object described by an FQPN,
        descending through several packages.
        """
        import xml.etree.ElementTree
        import automat

        for fqpn, obj in [
            ("xml.etree.ElementTree.fromstring", xml.etree.ElementTree.fromstring),
            ("automat.MethodicalMachine.__doc__", automat.MethodicalMachine.__doc__),
        ]:
            self.assertAttributeWrapperRefersTo(self.wrapFQPN(fqpn), fqpn, obj)

    def test_failsWithMultiplePackagesMissingModuleOrPackage(self):
        """
        L{wrapFQPN} raises L{NoObject} when given an FQPN that contains a
        missing attribute, module, or package.
        """
        for bad in ("xml.etree.nope!", "xml.etree.nope!.but.the.rest.is.believable"):
            with self.assertRaises(self.NoObject):
                self.wrapFQPN(bad)


@skipIf(not isTwistedInstalled(), "Twisted is not installed.")
class FindMachinesIntegrationTests(_WritesPythonModules):
    """
    Integration tests to check that L{findMachines} yields all
    machines discoverable at or below an FQPN.
    """

    SOURCE = """
    from automat import MethodicalMachine

    class PythonClass(object):
        _machine = MethodicalMachine()
        ignored = "i am ignored"

    rootLevel = MethodicalMachine()

    ignored = "i am ignored"
    """

    def setUp(self):
        super(FindMachinesIntegrationTests, self).setUp()
        from .._discover import findMachines

        self.findMachines = findMachines

        packageDir = self.FilePath(self.pathDir).child("test_package")
        packageDir.makedirs()
        self.pythonPath = self.PythonPath([self.pathDir])
        self.writeSourceInto(self.SOURCE, packageDir.path, "__init__.py")

        subPackageDir = packageDir.child("subpackage")
        subPackageDir.makedirs()
        subPackageDir.child("__init__.py").touch()

        self.makeModule(self.SOURCE, subPackageDir.path, "module.py")

        self.packageDict = self.loadModuleAsDict(self.pythonPath["test_package"])
        self.moduleDict = self.loadModuleAsDict(
            self.pythonPath["test_package"]["subpackage"]["module"]
        )

    def test_discoverAll(self):
        """
        Given a top-level package FQPN, L{findMachines} discovers all
        L{MethodicalMachine} instances in and below it.
        """
        machines = sorted(self.findMachines("test_package"), key=operator.itemgetter(0))

        tpRootLevel = self.packageDict["test_package.rootLevel"].load()
        tpPythonClass = self.packageDict["test_package.PythonClass"].load()

        mRLAttr = self.moduleDict["test_package.subpackage.module.rootLevel"]
        mRootLevel = mRLAttr.load()
        mPCAttr = self.moduleDict["test_package.subpackage.module.PythonClass"]
        mPythonClass = mPCAttr.load()

        expectedMachines = sorted(
            [
                ("test_package.rootLevel", tpRootLevel),
                ("test_package.PythonClass._machine", tpPythonClass._machine),
                ("test_package.subpackage.module.rootLevel", mRootLevel),
                (
                    "test_package.subpackage.module.PythonClass._machine",
                    mPythonClass._machine,
                ),
            ],
            key=operator.itemgetter(0),
        )

        self.assertEqual(expectedMachines, machines)
