"""Module for statical analysis."""
import contextlib

from jedi import debug
from jedi.evaluate.compiled import CompiledObject
from jedi.parser import tree

CODES = {
    'attribute-error': (1, AttributeError, 'Potential AttributeError.'),
    'name-error': (2, NameError, 'Potential NameError.'),
    'import-error': (3, ImportError, 'Potential ImportError.'),
    'type-error-generator': (4, TypeError, "TypeError: 'generator' object is not subscriptable."),
    'type-error-too-many-arguments': (5, TypeError, None),
    'type-error-too-few-arguments': (6, TypeError, None),
    'type-error-keyword-argument': (7, TypeError, None),
    'type-error-multiple-values': (8, TypeError, None),
    'type-error-star-star': (9, TypeError, None),
    'type-error-star': (10, TypeError, None),
    'type-error-operation': (11, TypeError, None),
}


class Error:
    def __init__(self, name, module_path, start_pos, message=None):
        self.path = module_path
        self._start_pos = start_pos
        self.name = name
        if message is None:
            message = CODES[self.name][2]
        self.message = message

    @property
    def line(self):
        return self._start_pos[0]

    @property
    def column(self):
        return self._start_pos[1]

    @property
    def code(self):
        # The class name start
        first = self.__class__.__name__[0]
        return first + str(CODES[self.name][0])

    def __unicode__(self):
        return f'{self.path}:{self.line}:{self.column}: {self.code} {self.message}'

    def __str__(self):
        return self.__unicode__()

    def __eq__(self, other):
        return (self.path == other.path and self.name == other.name
                and self._start_pos == other._start_pos)

    def __ne__(self, other):
        return not self.__eq__(other)

    def __hash__(self):
        return hash((self.path, self._start_pos, self.name))

    def __repr__(self):
        return f'<{self.__class__.__name__} {self.name}: {self.path}@{self._start_pos[0]},{self._start_pos[1]}>'


class Warning(Error):
    pass


def add(evaluator, name, jedi_obj, message=None, typ=Error, payload=None) -> None:
    from jedi.evaluate.iterable import MergedNodes
    while isinstance(jedi_obj, MergedNodes):
        if len(jedi_obj) != 1:
            # TODO is this kosher?
            return
        jedi_obj = list(jedi_obj)[0]

    exception = CODES[name][1]
    if _check_for_exception_catch(evaluator, jedi_obj, exception, payload):
        return

    module_path = jedi_obj.get_parent_until().path
    instance = typ(name, module_path, jedi_obj.start_pos, message)
    debug.warning(str(instance))
    evaluator.analysis.append(instance)


def _check_for_setattr(instance):
    """Check if there's any setattr method inside an instance. If so, return True."""
    module = instance.get_parent_until()
    try:
        stmts = module.used_names['setattr']
    except KeyError:
        return False

    return any(instance.start_pos < stmt.start_pos < instance.end_pos
               for stmt in stmts)


def add_attribute_error(evaluator, scope, name) -> None:
    message = (f'AttributeError: {scope} has no attribute {name}.')
    from jedi.evaluate.representation import Instance
    # Check for __getattr__/__getattribute__ existance and issue a warning
    # instead of an error, if that happens.
    if isinstance(scope, Instance):
        typ = Warning
        try:
            scope.get_subscope_by_name('__getattr__')
        except KeyError:
            try:
                scope.get_subscope_by_name('__getattribute__')
            except KeyError:
                if not _check_for_setattr(scope):
                    typ = Error
    else:
        typ = Error

    payload = scope, name
    add(evaluator, 'attribute-error', name, message, typ, payload)


def _check_for_exception_catch(evaluator, jedi_obj, exception, payload=None):
    """Checks if a jedi object (e.g. `Statement`) sits inside a try/catch and
    doesn't count as an error (if equal to `exception`).
    Also checks `hasattr` for AttributeErrors and uses the `payload` to compare
    it.
    Returns True if the exception was catched.
    """
    def check_match(cls, exception):
        try:
            return isinstance(cls, CompiledObject) and issubclass(exception, cls.obj)
        except TypeError:
            return False

    def check_try_for_except(obj, exception):
        # Only nodes in try
        iterator = iter(obj.children)
        for branch_type in iterator:
            next(iterator)
            suite = next(iterator)
            if branch_type == 'try' \
                    and not (branch_type.start_pos < jedi_obj.start_pos <= suite.end_pos):
                return False

        for node in obj.except_clauses():
            if node is None:
                return True  # An exception block that catches everything.
            except_classes = evaluator.eval_element(node)
            for cls in except_classes:
                from jedi.evaluate import iterable
                if isinstance(cls, iterable.Array) and cls.type == 'tuple':
                    # multiple exceptions
                    for c in cls.values():
                        if check_match(c, exception):
                            return True
                else:
                    if check_match(cls, exception):
                        return True
        return None

    def check_hasattr(node, suite):
        try:
            assert suite.start_pos <= jedi_obj.start_pos < suite.end_pos
            assert node.type == 'power'
            base = node.children[0]
            assert base.type == 'name' and base.value == 'hasattr'
            trailer = node.children[1]
            assert trailer.type == 'trailer'
            arglist = trailer.children[1]
            assert arglist.type == 'arglist'
            from jedi.evaluate.param import Arguments
            args = list(Arguments(evaluator, arglist).unpack())
            # Arguments should be very simple
            assert len(args) == 2

            # Check name
            key, values = args[1]
            assert len(values) == 1
            names = evaluator.eval_element(values[0])
            assert len(names) == 1 and isinstance(names[0], CompiledObject)
            assert names[0].obj == str(payload[1])

            # Check objects
            key, values = args[0]
            assert len(values) == 1
            objects = evaluator.eval_element(values[0])
            return payload[0] in objects
        except AssertionError:
            return False

    obj = jedi_obj
    while obj is not None and not obj.isinstance(tree.Function, tree.Class):
        if obj.isinstance(tree.Flow):
            # try/except catch check
            if obj.isinstance(tree.TryStmt) and check_try_for_except(obj, exception):
                return True
            # hasattr check
            if exception == AttributeError and obj.isinstance(tree.IfStmt, tree.WhileStmt):
                if check_hasattr(obj.children[1], obj.children[3]):
                    return True
        obj = obj.parent

    return False


def get_module_statements(module):
    """Returns the statements used in a module. All these statements should be
    evaluated to check for potential exceptions.
    """
    def check_children(node):
        try:
            children = node.children
        except AttributeError:
            return []
        else:
            nodes = []
            for child in children:
                nodes += check_children(child)
                if child.type == 'trailer':
                    c = child.children
                    if c[0] == '(' and c[1] != ')':
                        if c[1].type != 'arglist':
                            if c[1].type == 'argument':
                                nodes.append(c[1].children[-1])
                            else:
                                nodes.append(c[1])
                        else:
                            for argument in c[1].children:
                                if argument.type == 'argument':
                                    nodes.append(argument.children[-1])
                                elif argument.type != 'operator':
                                    nodes.append(argument)
            return nodes

    def add_nodes(nodes):
        new = set()
        for node in nodes:
            if isinstance(node, tree.Flow):
                children = node.children
                if node.type == 'for_stmt':
                    children = children[2:]  # Don't want to include the names.
                # Pick the suite/simple_stmt.
                new |= add_nodes(children)
            elif node.type in ('simple_stmt', 'suite'):
                new |= add_nodes(node.children)
            elif node.type in ('return_stmt', 'yield_expr'):
                with contextlib.suppress(IndexError):
                    new.add(node.children[1])
            elif node.type not in ('whitespace', 'operator', 'keyword',
                                   'parameters', 'decorated', 'except_clause') \
                    and not isinstance(node, tree.ClassOrFunc | tree.Import):
                new.add(node)

                try:
                    children = node.children
                except AttributeError:
                    pass
                else:
                    for next_node in children:
                        new.update(check_children(node))
                        if next_node.type != 'keyword' and node.type != 'expr_stmt':
                            new.add(node)
        return new

    nodes = set()
    import_names = set()
    decorated_funcs = []
    for scope in module.walk():
        for imp in set(scope.imports):
            import_names |= set(imp.get_defined_names())
            if imp.is_nested():
                import_names |= {path[-1] for path in imp.paths()}

        children = scope.children
        if isinstance(scope, tree.ClassOrFunc):
            children = children[2:]  # We don't want to include the class name.
        nodes |= add_nodes(children)

        for flow in scope.flows:
            if flow.type == 'for_stmt':
                nodes.add(flow.children[3])
            elif flow.type == 'try_stmt':
                nodes.update(e for e in flow.except_clauses() if e is not None)

        try:
            decorators = scope.get_decorators()
        except AttributeError:
            pass
        else:
            if decorators:
                decorated_funcs.append(scope)
    return nodes, import_names, decorated_funcs
