from __future__ import annotations

import collections
import inspect
from typing import Any, Iterator

from twisted.python.modules import PythonAttribute, PythonModule, getModule

from automat import MethodicalMachine

from ._typed import TypeMachine, InputProtocol, Core


def isOriginalLocation(attr: PythonAttribute | PythonModule) -> bool:
    """
    Attempt to discover if this appearance of a PythonAttribute
    representing a class refers to the module where that class was
    defined.
    """
    sourceModule = inspect.getmodule(attr.load())
    if sourceModule is None:
        return False

    currentModule = attr
    while not isinstance(currentModule, PythonModule):
        currentModule = currentModule.onObject

    return currentModule.name == sourceModule.__name__


def findMachinesViaWrapper(
    within: PythonModule | PythonAttribute,
) -> Iterator[tuple[str, MethodicalMachine | TypeMachine[InputProtocol, Core]]]:
    """
    Recursively yield L{MethodicalMachine}s and their FQPNs within a
    L{PythonModule} or a L{twisted.python.modules.PythonAttribute}
    wrapper object.

    Note that L{PythonModule}s may refer to packages, as well.

    The discovery heuristic considers L{MethodicalMachine} instances
    that are module-level attributes or class-level attributes
    accessible from module scope.  Machines inside nested classes will
    be discovered, but those returned from functions or methods will not be.

    @type within: L{PythonModule} or L{twisted.python.modules.PythonAttribute}
    @param within: Where to start the search.

    @return: a generator which yields FQPN, L{MethodicalMachine} pairs.
    """
    queue = collections.deque([within])
    visited: set[
        PythonModule
        | PythonAttribute
        | MethodicalMachine
        | TypeMachine[InputProtocol, Core]
        | type[Any]
    ] = set()

    while queue:
        attr = queue.pop()
        value = attr.load()
        if (
            isinstance(value, MethodicalMachine) or isinstance(value, TypeMachine)
        ) and value not in visited:
            visited.add(value)
            yield attr.name, value
        elif (
            inspect.isclass(value) and isOriginalLocation(attr) and value not in visited
        ):
            visited.add(value)
            queue.extendleft(attr.iterAttributes())
        elif isinstance(attr, PythonModule) and value not in visited:
            visited.add(value)
            queue.extendleft(attr.iterAttributes())
            queue.extendleft(attr.iterModules())


class InvalidFQPN(Exception):
    """
    The given FQPN was not a dot-separated list of Python objects.
    """


class NoModule(InvalidFQPN):
    """
    A prefix of the FQPN was not an importable module or package.
    """


class NoObject(InvalidFQPN):
    """
    A suffix of the FQPN was not an accessible object
    """


def wrapFQPN(fqpn: str) -> PythonModule | PythonAttribute:
    """
    Given an FQPN, retrieve the object via the global Python module
    namespace and wrap it with a L{PythonModule} or a
    L{twisted.python.modules.PythonAttribute}.
    """
    # largely cribbed from t.p.reflect.namedAny

    if not fqpn:
        raise InvalidFQPN("FQPN was empty")

    components = collections.deque(fqpn.split("."))

    if "" in components:
        raise InvalidFQPN(
            "name must be a string giving a '.'-separated list of Python "
            "identifiers, not %r" % (fqpn,)
        )

    component = components.popleft()
    try:
        module = getModule(component)
    except KeyError:
        raise NoModule(component)

    # find the bottom-most module
    while components:
        component = components.popleft()
        try:
            module = module[component]
        except KeyError:
            components.appendleft(component)
            break
        else:
            module.load()
    else:
        return module

    # find the bottom-most attribute
    attribute = module
    for component in components:
        try:
            attribute = next(
                child
                for child in attribute.iterAttributes()
                if child.name.rsplit(".", 1)[-1] == component
            )
        except StopIteration:
            raise NoObject("{}.{}".format(attribute.name, component))

    return attribute


def findMachines(
    fqpn: str,
) -> Iterator[tuple[str, MethodicalMachine | TypeMachine[InputProtocol, Core]]]:
    """
    Recursively yield L{MethodicalMachine}s and their FQPNs in and under the a
    Python object specified by an FQPN.

    The discovery heuristic considers L{MethodicalMachine} instances that are
    module-level attributes or class-level attributes accessible from module
    scope.  Machines inside nested classes will be discovered, but those
    returned from functions or methods will not be.

    @param fqpn: a fully-qualified Python identifier (i.e. the dotted
        identifier of an object defined at module or class scope, including the
        package and modele names); where to start the search.

    @return: a generator which yields (C{FQPN}, L{MethodicalMachine}) pairs.
    """
    return findMachinesViaWrapper(wrapFQPN(fqpn))
