# Natural Language Toolkit: First-Order Tableau Theorem Prover
#
# Copyright (C) 2001-2018 NLTK Project
# Author: Dan Garrette <dhgarrette@gmail.com>
#
# URL: <http://nltk.org/>
# For license information, see LICENSE.TXT

"""
Module for a tableau-based First Order theorem prover.
"""
from __future__ import print_function, unicode_literals

from nltk.internals import Counter

from nltk.sem.logic import (VariableExpression, EqualityExpression,
                            ApplicationExpression, Expression,
                            AbstractVariableExpression, AllExpression,
                            NegatedExpression,
                            ExistsExpression, Variable, ImpExpression,
                            AndExpression, unique_variable,
                            LambdaExpression, IffExpression,
                            OrExpression, FunctionVariableExpression)

from nltk.inference.api import Prover, BaseProverCommand

_counter = Counter()

class ProverParseError(Exception): pass

class TableauProver(Prover):
    _assume_false=False

    def _prove(self, goal=None, assumptions=None, verbose=False):
        if not assumptions:
            assumptions = []

        result = None
        try:
            agenda = Agenda()
            if goal:
                agenda.put(-goal)
            agenda.put_all(assumptions)
            debugger = Debug(verbose)
            result = self._attempt_proof(agenda, set(), set(), debugger)
        except RuntimeError as e:
            if self._assume_false and str(e).startswith('maximum recursion depth exceeded'):
                result = False
            else:
                if verbose:
                    print(e)
                else:
                    raise e
        return (result, '\n'.join(debugger.lines))

    def _attempt_proof(self, agenda, accessible_vars, atoms, debug):
        (current, context), category = agenda.pop_first()

        #if there's nothing left in the agenda, and we haven't closed the path
        if not current:
            debug.line('AGENDA EMPTY')
            return False

        proof_method = { Categories.ATOM:     self._attempt_proof_atom,
                         Categories.PROP:     self._attempt_proof_prop,
                         Categories.N_ATOM:   self._attempt_proof_n_atom,
                         Categories.N_PROP:   self._attempt_proof_n_prop,
                         Categories.APP:      self._attempt_proof_app,
                         Categories.N_APP:    self._attempt_proof_n_app,
                         Categories.N_EQ:     self._attempt_proof_n_eq,
                         Categories.D_NEG:    self._attempt_proof_d_neg,
                         Categories.N_ALL:    self._attempt_proof_n_all,
                         Categories.N_EXISTS: self._attempt_proof_n_some,
                         Categories.AND:      self._attempt_proof_and,
                         Categories.N_OR:     self._attempt_proof_n_or,
                         Categories.N_IMP:    self._attempt_proof_n_imp,
                         Categories.OR:       self._attempt_proof_or,
                         Categories.IMP:      self._attempt_proof_imp,
                         Categories.N_AND:    self._attempt_proof_n_and,
                         Categories.IFF:      self._attempt_proof_iff,
                         Categories.N_IFF:    self._attempt_proof_n_iff,
                         Categories.EQ:       self._attempt_proof_eq,
                         Categories.EXISTS:   self._attempt_proof_some,
                         Categories.ALL:      self._attempt_proof_all,
                        }[category]

        debug.line((current, context))
        return proof_method(current, context, agenda, accessible_vars, atoms, debug)

    def _attempt_proof_atom(self, current, context, agenda, accessible_vars, atoms, debug):
        # Check if the branch is closed.  Return 'True' if it is
        if (current, True) in atoms:
            debug.line('CLOSED', 1)
            return True

        if context:
            if isinstance(context.term, NegatedExpression):
                current = current.negate()
            agenda.put(context(current).simplify())
            return self._attempt_proof(agenda, accessible_vars, atoms, debug+1)
        else:
            #mark all AllExpressions as 'not exhausted' into the agenda since we are (potentially) adding new accessible vars
            agenda.mark_alls_fresh();
            return self._attempt_proof(agenda, accessible_vars|set(current.args), atoms|set([(current, False)]), debug+1)

    def _attempt_proof_n_atom(self, current, context, agenda, accessible_vars, atoms, debug):
        # Check if the branch is closed.  Return 'True' if it is
        if (current.term, False) in atoms:
            debug.line('CLOSED', 1)
            return True

        if context:
            if isinstance(context.term, NegatedExpression):
                current = current.negate()
            agenda.put(context(current).simplify())
            return self._attempt_proof(agenda, accessible_vars, atoms, debug+1)
        else:
            #mark all AllExpressions as 'not exhausted' into the agenda since we are (potentially) adding new accessible vars
            agenda.mark_alls_fresh();
            return self._attempt_proof(agenda, accessible_vars|set(current.term.args), atoms|set([(current.term, True)]), debug+1)

    def _attempt_proof_prop(self, current, context, agenda, accessible_vars, atoms, debug):
        # Check if the branch is closed.  Return 'True' if it is
        if (current, True) in atoms:
            debug.line('CLOSED', 1)
            return True

        #mark all AllExpressions as 'not exhausted' into the agenda since we are (potentially) adding new accessible vars
        agenda.mark_alls_fresh();
        return self._attempt_proof(agenda, accessible_vars, atoms|set([(current, False)]), debug+1)

    def _attempt_proof_n_prop(self, current, context, agenda, accessible_vars, atoms, debug):
        # Check if the branch is closed.  Return 'True' if it is
        if (current.term, False) in atoms:
            debug.line('CLOSED', 1)
            return True

        #mark all AllExpressions as 'not exhausted' into the agenda since we are (potentially) adding new accessible vars
        agenda.mark_alls_fresh();
        return self._attempt_proof(agenda, accessible_vars, atoms|set([(current.term, True)]), debug+1)

    def _attempt_proof_app(self, current, context, agenda, accessible_vars, atoms, debug):
        f, args = current.uncurry()
        for i, arg in enumerate(args):
            if not TableauProver.is_atom(arg):
                ctx = f
                nv = Variable('X%s' % _counter.get())
                for j,a in enumerate(args):
                    ctx = (ctx(VariableExpression(nv)) if i == j else ctx(a))
                if context:
                    ctx = context(ctx).simplify()
                ctx = LambdaExpression(nv, ctx)
                agenda.put(arg, ctx)
                return self._attempt_proof(agenda, accessible_vars, atoms, debug+1)
        raise Exception('If this method is called, there must be a non-atomic argument')

    def _attempt_proof_n_app(self, current, context, agenda, accessible_vars, atoms, debug):
        f, args = current.term.uncurry()
        for i, arg in enumerate(args):
            if not TableauProver.is_atom(arg):
                ctx = f
                nv = Variable('X%s' % _counter.get())
                for j,a in enumerate(args):
                    ctx = (ctx(VariableExpression(nv)) if i == j else ctx(a))
                if context:
                    #combine new context with existing
                    ctx = context(ctx).simplify()
                ctx = LambdaExpression(nv, -ctx)
                agenda.put(-arg, ctx)
                return self._attempt_proof(agenda, accessible_vars, atoms, debug+1)
        raise Exception('If this method is called, there must be a non-atomic argument')

    def _attempt_proof_n_eq(self, current, context, agenda, accessible_vars, atoms, debug):
        ###########################################################################
        # Since 'current' is of type '~(a=b)', the path is closed if 'a' == 'b'
        ###########################################################################
        if current.term.first == current.term.second:
            debug.line('CLOSED', 1)
            return True

        agenda[Categories.N_EQ].add((current,context))
        current._exhausted = True
        return self._attempt_proof(agenda, accessible_vars|set([current.term.first, current.term.second]), atoms, debug+1)

    def _attempt_proof_d_neg(self, current, context, agenda, accessible_vars, atoms, debug):
        agenda.put(current.term.term, context)
        return self._attempt_proof(agenda, accessible_vars, atoms, debug+1)

    def _attempt_proof_n_all(self, current, context, agenda, accessible_vars, atoms, debug):
        agenda[Categories.EXISTS].add((ExistsExpression(current.term.variable, -current.term.term), context))
        return self._attempt_proof(agenda, accessible_vars, atoms, debug+1)

    def _attempt_proof_n_some(self, current, context, agenda, accessible_vars, atoms, debug):
        agenda[Categories.ALL].add((AllExpression(current.term.variable, -current.term.term), context))
        return self._attempt_proof(agenda, accessible_vars, atoms, debug+1)

    def _attempt_proof_and(self, current, context, agenda, accessible_vars, atoms, debug):
        agenda.put(current.first, context)
        agenda.put(current.second, context)
        return self._attempt_proof(agenda, accessible_vars, atoms, debug+1)

    def _attempt_proof_n_or(self, current, context, agenda, accessible_vars, atoms, debug):
        agenda.put(-current.term.first, context)
        agenda.put(-current.term.second, context)
        return self._attempt_proof(agenda, accessible_vars, atoms, debug+1)

    def _attempt_proof_n_imp(self, current, context, agenda, accessible_vars, atoms, debug):
        agenda.put(current.term.first, context)
        agenda.put(-current.term.second, context)
        return self._attempt_proof(agenda, accessible_vars, atoms, debug+1)

    def _attempt_proof_or(self, current, context, agenda, accessible_vars, atoms, debug):
        new_agenda = agenda.clone()
        agenda.put(current.first, context)
        new_agenda.put(current.second, context)
        return self._attempt_proof(agenda, accessible_vars, atoms, debug+1) and \
                self._attempt_proof(new_agenda, accessible_vars, atoms, debug+1)

    def _attempt_proof_imp(self, current, context, agenda, accessible_vars, atoms, debug):
        new_agenda = agenda.clone()
        agenda.put(-current.first, context)
        new_agenda.put(current.second, context)
        return self._attempt_proof(agenda, accessible_vars, atoms, debug+1) and \
                self._attempt_proof(new_agenda, accessible_vars, atoms, debug+1)

    def _attempt_proof_n_and(self, current, context, agenda, accessible_vars, atoms, debug):
        new_agenda = agenda.clone()
        agenda.put(-current.term.first, context)
        new_agenda.put(-current.term.second, context)
        return self._attempt_proof(agenda, accessible_vars, atoms, debug+1) and \
                self._attempt_proof(new_agenda, accessible_vars, atoms, debug+1)

    def _attempt_proof_iff(self, current, context, agenda, accessible_vars, atoms, debug):
        new_agenda = agenda.clone()
        agenda.put(current.first, context)
        agenda.put(current.second, context)
        new_agenda.put(-current.first, context)
        new_agenda.put(-current.second, context)
        return self._attempt_proof(agenda, accessible_vars, atoms, debug+1) and \
                self._attempt_proof(new_agenda, accessible_vars, atoms, debug+1)

    def _attempt_proof_n_iff(self, current, context, agenda, accessible_vars, atoms, debug):
        new_agenda = agenda.clone()
        agenda.put(current.term.first, context)
        agenda.put(-current.term.second, context)
        new_agenda.put(-current.term.first, context)
        new_agenda.put(current.term.second, context)
        return self._attempt_proof(agenda, accessible_vars, atoms, debug+1) and \
                self._attempt_proof(new_agenda, accessible_vars, atoms, debug+1)

    def _attempt_proof_eq(self, current, context, agenda, accessible_vars, atoms, debug):
        #########################################################################
        # Since 'current' is of the form '(a = b)', replace ALL free instances
        # of 'a' with 'b'
        #########################################################################
        agenda.put_atoms(atoms)
        agenda.replace_all(current.first, current.second)
        accessible_vars.discard(current.first)
        agenda.mark_neqs_fresh();
        return self._attempt_proof(agenda, accessible_vars, set(), debug+1)

    def _attempt_proof_some(self, current, context, agenda, accessible_vars, atoms, debug):
        new_unique_variable = VariableExpression(unique_variable())
        agenda.put(current.term.replace(current.variable, new_unique_variable), context)
        agenda.mark_alls_fresh()
        return self._attempt_proof(agenda, accessible_vars|set([new_unique_variable]), atoms, debug+1)

    def _attempt_proof_all(self, current, context, agenda, accessible_vars, atoms, debug):
        try:
            current._used_vars
        except AttributeError:
            current._used_vars = set()

        #if there are accessible_vars on the path
        if accessible_vars:
            # get the set of bound variables that have not be used by this AllExpression
            bv_available = accessible_vars - current._used_vars

            if bv_available:
                variable_to_use = list(bv_available)[0]
                debug.line('--> Using \'%s\'' % variable_to_use, 2)
                current._used_vars |= set([variable_to_use])
                agenda.put(current.term.replace(current.variable, variable_to_use), context)
                agenda[Categories.ALL].add((current,context))
                return self._attempt_proof(agenda, accessible_vars, atoms, debug+1)

            else:
                #no more available variables to substitute
                debug.line('--> Variables Exhausted', 2)
                current._exhausted = True
                agenda[Categories.ALL].add((current,context))
                return self._attempt_proof(agenda, accessible_vars, atoms, debug+1)

        else:
            new_unique_variable = VariableExpression(unique_variable())
            debug.line('--> Using \'%s\'' % new_unique_variable, 2)
            current._used_vars |= set([new_unique_variable])
            agenda.put(current.term.replace(current.variable, new_unique_variable), context)
            agenda[Categories.ALL].add((current,context))
            agenda.mark_alls_fresh()
            return self._attempt_proof(agenda, accessible_vars|set([new_unique_variable]), atoms, debug+1)

    @staticmethod
    def is_atom(e):
        if isinstance(e, NegatedExpression):
            e = e.term

        if isinstance(e, ApplicationExpression):
            for arg in e.args:
                if not TableauProver.is_atom(arg):
                    return False
            return True
        elif isinstance(e, AbstractVariableExpression) or \
             isinstance(e, LambdaExpression):
            return True
        else:
            return False


class TableauProverCommand(BaseProverCommand):
    def __init__(self, goal=None, assumptions=None, prover=None):
        """
        :param goal: Input expression to prove
        :type goal: sem.Expression
        :param assumptions: Input expressions to use as assumptions in
            the proof.
        :type assumptions: list(sem.Expression)
        """
        if prover is not None:
            assert isinstance(prover, TableauProver)
        else:
            prover = TableauProver()

        BaseProverCommand.__init__(self, prover, goal, assumptions)


class Agenda(object):
    def __init__(self):
        self.sets = tuple(set() for i in range(21))

    def clone(self):
        new_agenda = Agenda()
        set_list = [s.copy() for s in self.sets]

        new_allExs = set()
        for allEx,_ in set_list[Categories.ALL]:
            new_allEx = AllExpression(allEx.variable, allEx.term)
            try:
                new_allEx._used_vars = set(used for used in allEx._used_vars)
            except AttributeError:
                new_allEx._used_vars = set()
            new_allExs.add((new_allEx,None))
        set_list[Categories.ALL] = new_allExs

        set_list[Categories.N_EQ] = set((NegatedExpression(n_eq.term),ctx)
                                        for (n_eq,ctx) in set_list[Categories.N_EQ])

        new_agenda.sets = tuple(set_list)
        return new_agenda

    def __getitem__(self, index):
        return self.sets[index]

    def put(self, expression, context=None):
        if isinstance(expression, AllExpression):
            ex_to_add = AllExpression(expression.variable, expression.term)
            try:
                ex_to_add._used_vars = set(used for used in expression._used_vars)
            except AttributeError:
                ex_to_add._used_vars = set()
        else:
            ex_to_add = expression
        self.sets[self._categorize_expression(ex_to_add)].add((ex_to_add, context))

    def put_all(self, expressions):
        for expression in expressions:
            self.put(expression)

    def put_atoms(self, atoms):
        for atom, neg in atoms:
            if neg:
                self[Categories.N_ATOM].add((-atom,None))
            else:
                self[Categories.ATOM].add((atom,None))

    def pop_first(self):
        """ Pop the first expression that appears in the agenda """
        for i,s in enumerate(self.sets):
            if s:
                if i in [Categories.N_EQ, Categories.ALL]:
                    for ex in s:
                        try:
                            if not ex[0]._exhausted:
                                s.remove(ex)
                                return (ex, i)
                        except AttributeError:
                            s.remove(ex)
                            return (ex, i)
                else:
                    return (s.pop(), i)
        return ((None, None), None)

    def replace_all(self, old, new):
        for s in self.sets:
            for ex,ctx in s:
                ex.replace(old.variable, new)
                if ctx is not None:
                    ctx.replace(old.variable, new)

    def mark_alls_fresh(self):
        for u,_ in self.sets[Categories.ALL]:
            u._exhausted = False

    def mark_neqs_fresh(self):
        for neq,_ in self.sets[Categories.N_EQ]:
            neq._exhausted = False

    def _categorize_expression(self, current):
        if isinstance(current, NegatedExpression):
            return self._categorize_NegatedExpression(current)
        elif isinstance(current, FunctionVariableExpression):
            return Categories.PROP
        elif TableauProver.is_atom(current):
            return Categories.ATOM
        elif isinstance(current, AllExpression):
            return Categories.ALL
        elif isinstance(current, AndExpression):
            return Categories.AND
        elif isinstance(current, OrExpression):
            return Categories.OR
        elif isinstance(current, ImpExpression):
            return Categories.IMP
        elif isinstance(current, IffExpression):
            return Categories.IFF
        elif isinstance(current, EqualityExpression):
            return Categories.EQ
        elif isinstance(current, ExistsExpression):
            return Categories.EXISTS
        elif isinstance(current, ApplicationExpression):
            return Categories.APP
        else:
            raise ProverParseError("cannot categorize %s" % \
                                   current.__class__.__name__)

    def _categorize_NegatedExpression(self, current):
        negated = current.term

        if isinstance(negated, NegatedExpression):
            return Categories.D_NEG
        elif isinstance(negated, FunctionVariableExpression):
            return Categories.N_PROP
        elif TableauProver.is_atom(negated):
            return Categories.N_ATOM
        elif isinstance(negated, AllExpression):
            return Categories.N_ALL
        elif isinstance(negated, AndExpression):
            return Categories.N_AND
        elif isinstance(negated, OrExpression):
            return Categories.N_OR
        elif isinstance(negated, ImpExpression):
            return Categories.N_IMP
        elif isinstance(negated, IffExpression):
            return Categories.N_IFF
        elif isinstance(negated, EqualityExpression):
            return Categories.N_EQ
        elif isinstance(negated, ExistsExpression):
            return Categories.N_EXISTS
        elif isinstance(negated, ApplicationExpression):
            return Categories.N_APP
        else:
            raise ProverParseError("cannot categorize %s" % \
                                   negated.__class__.__name__)


class Debug(object):
    def __init__(self, verbose, indent=0, lines=None):
        self.verbose = verbose
        self.indent = indent

        if not lines: lines = []
        self.lines = lines

    def __add__(self, increment):
        return Debug(self.verbose, self.indent+1, self.lines)

    def line(self, data, indent=0):
        if isinstance(data, tuple):
            ex, ctx = data
            if ctx:
                data = '%s, %s' % (ex, ctx)
            else:
                data = '%s' % ex

            if isinstance(ex, AllExpression):
                try:
                    used_vars = "[%s]" % (",".join("%s" % ve.variable.name for ve in ex._used_vars))
                    data += ':   %s' % used_vars
                except AttributeError:
                    data += ':   []'

        newline = '%s%s' % ('   '*(self.indent+indent), data)
        self.lines.append(newline)

        if self.verbose:
            print(newline)


class Categories(object):
    ATOM     = 0
    PROP     = 1
    N_ATOM   = 2
    N_PROP   = 3
    APP      = 4
    N_APP    = 5
    N_EQ     = 6
    D_NEG    = 7
    N_ALL    = 8
    N_EXISTS = 9
    AND      = 10
    N_OR     = 11
    N_IMP    = 12
    OR       = 13
    IMP      = 14
    N_AND    = 15
    IFF      = 16
    N_IFF    = 17
    EQ       = 18
    EXISTS   = 19
    ALL      = 20


def testTableauProver():
    tableau_test('P | -P')
    tableau_test('P & -P')
    tableau_test('Q', ['P', '(P -> Q)'])
    tableau_test('man(x)')
    tableau_test('(man(x) -> man(x))')
    tableau_test('(man(x) -> --man(x))')
    tableau_test('-(man(x) and -man(x))')
    tableau_test('(man(x) or -man(x))')
    tableau_test('(man(x) -> man(x))')
    tableau_test('-(man(x) and -man(x))')
    tableau_test('(man(x) or -man(x))')
    tableau_test('(man(x) -> man(x))')
    tableau_test('(man(x) iff man(x))')
    tableau_test('-(man(x) iff -man(x))')
    tableau_test('all x.man(x)')
    tableau_test('all x.all y.((x = y) -> (y = x))')
    tableau_test('all x.all y.all z.(((x = y) & (y = z)) -> (x = z))')
#    tableau_test('-all x.some y.F(x,y) & some x.all y.(-F(x,y))')
#    tableau_test('some x.all y.sees(x,y)')

    p1 = 'all x.(man(x) -> mortal(x))'
    p2 = 'man(Socrates)'
    c = 'mortal(Socrates)'
    tableau_test(c, [p1, p2])

    p1 = 'all x.(man(x) -> walks(x))'
    p2 = 'man(John)'
    c = 'some y.walks(y)'
    tableau_test(c, [p1, p2])

    p = '((x = y) & walks(y))'
    c = 'walks(x)'
    tableau_test(c, [p])

    p = '((x = y) & ((y = z) & (z = w)))'
    c = '(x = w)'
    tableau_test(c, [p])

    p = 'some e1.some e2.(believe(e1,john,e2) & walk(e2,mary))'
    c = 'some e0.walk(e0,mary)'
    tableau_test(c, [p])

    c = '(exists x.exists z3.((x = Mary) & ((z3 = John) & sees(z3,x))) <-> exists x.exists z4.((x = John) & ((z4 = Mary) & sees(x,z4))))'
    tableau_test(c)

#    p = 'some e1.some e2.((believe e1 john e2) and (walk e2 mary))'
#    c = 'some x.some e3.some e4.((believe e3 x e4) and (walk e4 mary))'
#    tableau_test(c, [p])


def testHigherOrderTableauProver():
    tableau_test('believe(j, -lie(b))', ['believe(j, -lie(b) & -cheat(b))'])
    tableau_test('believe(j, lie(b) & cheat(b))', ['believe(j, lie(b))'])
    tableau_test('believe(j, lie(b))', ['lie(b)']) #how do we capture that John believes all things that are true
    tableau_test('believe(j, know(b, cheat(b)))', ['believe(j, know(b, lie(b)) & know(b, steals(b) & cheat(b)))'])
    tableau_test('P(Q(y), R(y) & R(z))', ['P(Q(x) & Q(y), R(y) & R(z))'])

    tableau_test('believe(j, cheat(b) & lie(b))', ['believe(j, lie(b) & cheat(b))'])
    tableau_test('believe(j, -cheat(b) & -lie(b))', ['believe(j, -lie(b) & -cheat(b))'])


def tableau_test(c, ps=None, verbose=False):
    pc = Expression.fromstring(c)
    pps = ([Expression.fromstring(p) for p in ps] if ps else [])
    if not ps:
        ps = []
    print('%s |- %s: %s' % (', '.join(ps), pc, TableauProver().prove(pc, pps, verbose=verbose)))

def demo():
    testTableauProver()
    testHigherOrderTableauProver()

if __name__ == '__main__':
    demo()

