"""Self-running tests. No pytest, no dependencies:

    python3 test_interp.py

The expression enumeration below is written out again rather than imported
from `demo.py` on purpose: the headline counts are checked against an
*independent* generator, so a bug in the demo's enumerator cannot make the
demo and the test agree on a wrong number.
"""

import ast
import itertools
import sys
from fractions import Fraction

from interp import (
    ADD_FLIPPED,
    CORRECT,
    FLIPPED,
    Parser,
    evaluate,
    parse,
    run,
    tokenize,
)

ALL_LEFT = ("left", "left", "left")


# --- the tokenizer

def test_tokenizer():
    assert tokenize("2-3-4") == [
        ("num", 2), ("-", "-"), ("num", 3), ("-", "-"), ("num", 4),
        ("eof", None)]
    assert tokenize("  1 +\t2 ") == [
        ("num", 1), ("+", "+"), ("num", 2), ("eof", None)]
    assert tokenize("100/10") == [
        ("num", 100), ("/", "/"), ("num", 10), ("eof", None)]
    assert tokenize("") == [("eof", None)]
    try:
        tokenize("2 $ 3")
    except SyntaxError as exc:
        assert "stray" in str(exc)
    else:
        raise AssertionError("stray character should not tokenize")


# --- precedence is one knob

def test_precedence():
    assert repr(parse("1+2*3")) == "(1 + (2 * 3))"
    assert repr(parse("1*2+3")) == "((1 * 2) + 3)"
    assert repr(parse("(1+2)*3")) == "((1 + 2) * 3)"
    assert repr(parse("2*3^2")) == "(2 * (3 ^ 2))"
    # precedence does not move when associativity does
    assert repr(parse("1+2*3", FLIPPED)) == "(1 + (2 * 3))"


# --- associativity is a different knob, and it is THE line

def test_associativity():
    assert repr(parse("2-3-4", CORRECT)) == "((2 - 3) - 4)"
    assert repr(parse("2-3-4", FLIPPED)) == "(2 - (3 - 4))"
    assert run("2-3-4", CORRECT) == -5
    assert run("2-3-4", FLIPPED) == 3
    assert repr(parse("2-3-4-5", CORRECT)) == "(((2 - 3) - 4) - 5)"
    assert repr(parse("2-3-4-5", FLIPPED)) == "(2 - (3 - (4 - 5)))"
    # ADD_FLIPPED leaves the multiplicative level alone
    assert repr(parse("2/3/4", ADD_FLIPPED)) == "((2 / 3) / 4)"
    assert repr(parse("2/3/4", FLIPPED)) == "(2 / (3 / 4))"
    try:
        Parser(tokenize("1"), ("left", "left"))
    except ValueError:
        pass
    else:
        raise AssertionError("assoc must have one entry per level")


# --- the same line, as the fix

def test_power_is_right_associative_on_purpose():
    assert repr(parse("2^3^2", CORRECT)) == "(2 ^ (3 ^ 2))"
    assert repr(parse("2^3^2", ALL_LEFT)) == "((2 ^ 3) ^ 2)"
    assert run("2^3^2", CORRECT) == 512 == 2 ** 3 ** 2
    assert run("2^3^2", ALL_LEFT) == 64 == (2 ** 3) ** 2
    assert run("2^2^3", CORRECT) == 2 ** 2 ** 3
    assert run("4^3^2", CORRECT) == 4 ** 3 ** 2


# --- prefix minus

def test_unary():
    assert repr(parse("-2*3")) == "((-2) * 3)"
    assert repr(parse("--2")) == "(-(-2))"
    assert run("--2") == 2
    # prefix minus binds looser than '^', exactly as in Python
    assert repr(parse("-2^2")) == "(-(2 ^ 2))"
    assert run("-2^2") == -4 == -2 ** 2
    assert run("2^-2") == Fraction(1, 4)
    # and it does not create an associativity difference of its own
    assert run("-2-3", CORRECT) == run("-2-3", FLIPPED) == -5
    assert run("2*-3*-4", CORRECT) == run("2*-3*-4", FLIPPED) == 24


# --- exact arithmetic, and the errors

def test_evaluator():
    assert run("2/3/4") == Fraction(1, 6)
    assert run("1/3") * 3 == 1                      # exact, not 0.9999...
    assert isinstance(run("1+1"), Fraction)
    assert run("100/10/2") == 5
    for bad, exc in (("1/0", ZeroDivisionError), ("2/(3-3)", ZeroDivisionError)):
        try:
            run(bad)
        except exc:
            continue
        raise AssertionError(f"{bad} should raise {exc.__name__}")
    try:
        run("4^(1/2)")
    except ValueError:
        pass
    else:
        raise AssertionError("fractional exponent should be refused")


def test_syntax_errors():
    for bad in ("2+", "2 3", "(2", "*3", "2**3", ")"):
        try:
            parse(bad)
        except SyntaxError:
            continue
        raise AssertionError(f"{bad!r} should be a SyntaxError")


# --- the headline counts, over an independently generated space

def expressions(n, ops=("+", "-", "*", "/"), operands=("2", "3", "4")):
    for nums in itertools.product(operands, repeat=n):
        for opseq in itertools.product(ops, repeat=n - 1):
            out = nums[0]
            for op, operand in zip(opseq, nums[1:]):
                out += op + operand
            yield out, opseq


def agreement(n, assoc, ops=("+", "-", "*", "/")):
    total = agree = 0
    for source, _ in expressions(n, ops):
        total += 1
        agree += run(source, CORRECT) == run(source, assoc)
    return total, agree


def test_headline_counts():
    assert agreement(3, FLIPPED) == (432, 324)
    assert agreement(3, ADD_FLIPPED) == (432, 378)
    assert agreement(4, FLIPPED) == (5184, 2646)
    assert agreement(4, ADD_FLIPPED) == (5184, 3753)
    assert agreement(5, FLIPPED) == (62208, 20802)
    assert agreement(5, ADD_FLIPPED) == (62208, 36171)


def test_which_operator_pairs():
    culprits = {}
    for source, opseq in expressions(3):
        if run(source, CORRECT) != run(source, FLIPPED):
            culprits["".join(opseq)] = culprits.get("".join(opseq), 0) + 1
    assert culprits == {"-+": 27, "--": 27, "/*": 27, "//": 27}
    assert sum(culprits.values()) == 108


def test_boundary_associative_operators():
    for ops in (("+",), ("*",), ("+", "*")):
        for n in (3, 4, 5, 6):
            total, agree = agreement(n, FLIPPED, ops)
            assert total == agree, (ops, n, total, agree)
    assert agreement(6, FLIPPED, ("+", "*")) == (23328, 23328)
    # ... and the non-associative ones can never agree on their own
    assert agreement(3, FLIPPED, ("-",)) == (27, 0)
    assert agreement(3, FLIPPED, ("/",)) == (27, 0)
    assert agreement(4, FLIPPED, ("-", "/")) == (648, 81)


def test_parentheses_rescue():
    for source in ("(2-3)-4", "2-(3-4)", "((2-3)-4)", "2-(3+4)"):
        assert run(source, CORRECT) == run(source, FLIPPED), source


def test_hand_written_suite():
    suite = ("1+2", "2*3", "10-4", "8/2", "1+2*3", "(1+2)*3", "2*3+4",
             "4+2*3", "10-2-3", "100/10/2", "2*(3+4)", "1+2+3+4", "-5+3",
             "6/3*2", "(2+3)*(4-1)", "7", "2*3*4", "1+2-3+4")
    caught = [s for s in suite if run(s, CORRECT) != run(s, FLIPPED)]
    assert caught == ["10-2-3", "100/10/2", "6/3*2", "1+2-3+4"]
    assert len(caught) == 4 and len(suite) == 18


def test_evaluator_is_not_where_grouping_lives():
    """Same evaluator, two trees, two answers -- the point of the toy."""
    left = parse("2-3-4", CORRECT)
    right = parse("2-3-4", FLIPPED)
    assert evaluate(left) != evaluate(right)
    assert tokenize("2-3-4") == tokenize("2-3-4")  # one token stream


def main():
    tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
    for test in tests:
        test()
    source = ast.parse(open(__file__).read())
    asserts = sum(isinstance(n, ast.Assert) for n in ast.walk(source))
    print(f"ok: {len(tests)} tests, {asserts} assertions")
    return 0


if __name__ == "__main__":
    sys.exit(main())
