#!/usr/bin/env python3
"""Small, dependency-free public autograder for the Python refresher.

Examples:
    python autograder.py readText.py
    python autograder.py readCSV.py
    python autograder.py countNames.py
    python autograder.py              # run all three questions
"""

import argparse
import ast
import contextlib
import importlib.util
import io
import os
import re
import sys
import tempfile
import types


HERE = os.path.dirname(os.path.abspath(__file__))


class TestFailure(AssertionError):
    """An assertion failure whose message is suitable for student output."""


def check(condition, message):
    if not condition:
        raise TestFailure(message)


def check_equal(actual, expected):
    if actual != expected:
        raise TestFailure(
            "Expected {!r}, but your function returned {!r}.".format(expected, actual)
        )


def load_module(module_name, filename, skip_main_call=False):
    """Load a student file, optionally without its final top-level main() call."""
    path = os.path.join(HERE, filename)
    if not os.path.isfile(path):
        raise TestFailure("Could not find {} next to autograder.py.".format(filename))

    if not skip_main_call:
        spec = importlib.util.spec_from_file_location(module_name, path)
        module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(module)
        return module

    # countNames.py's starter code calls main() at import time.  Skip only that
    # top-level call so helper functions can still be tested independently.
    with open(path, "r", encoding="utf-8") as source_file:
        tree = ast.parse(source_file.read(), filename=path)
    tree.body = [
        node
        for node in tree.body
        if not (
            isinstance(node, ast.Expr)
            and isinstance(node.value, ast.Call)
            and isinstance(node.value.func, ast.Name)
            and node.value.func.id == "main"
        )
    ]
    ast.fix_missing_locations(tree)

    module = types.ModuleType(module_name)
    module.__file__ = path
    module.__package__ = ""
    exec(compile(tree, path, "exec"), module.__dict__)
    return module


def require_function(module, function_name):
    function = getattr(module, function_name, None)
    if not callable(function):
        raise TestFailure(
            "{}.py must define a function named {}().".format(
                os.path.splitext(os.path.basename(module.__file__))[0],
                function_name,
            )
        )
    return function


def test_read_text_lines():
    module = load_module("student_readText", "readText.py")
    read_text = require_function(module, "read_text")
    with tempfile.TemporaryDirectory() as temp_dir:
        path = os.path.join(temp_dir, "sample.txt")
        with open(path, "w", encoding="utf-8", newline="") as sample:
            sample.write("first line\nsecond line\nlast line")
        check_equal(
            read_text(path),
            ["first line\n", "second line\n", "last line"],
        )


def test_read_text_small_files():
    module = load_module("student_readText_small", "readText.py")
    read_text = require_function(module, "read_text")
    with tempfile.TemporaryDirectory() as temp_dir:
        one_line_path = os.path.join(temp_dir, "one_line.txt")
        with open(one_line_path, "w", encoding="utf-8", newline="") as sample:
            sample.write("one line\n")
        check_equal(read_text(one_line_path), ["one line\n"])

        empty_path = os.path.join(temp_dir, "empty.txt")
        with open(empty_path, "w", encoding="utf-8"):
            pass
        check_equal(read_text(empty_path), [])


def test_csv_names():
    module = load_module("student_readCSV", "readCSV.py")
    get_petnames = require_function(module, "get_petnames")
    with tempfile.TemporaryDirectory() as temp_dir:
        path = os.path.join(temp_dir, "pets.csv")
        with open(path, "w", encoding="utf-8", newline="") as sample:
            sample.write(
                "Animal ID,Name,Animal Type\r\n"
                'A1,"Fluffy, Jr.",Cat\r\n'
                "A2,Luna,Dog\r\n"
                'A3,"Sir ""Barks""",Dog\r\n'
            )
        check_equal(
            get_petnames(path),
            ["Name", "Fluffy, Jr.", "Luna", 'Sir "Barks"'],
        )


def test_csv_header_only():
    module = load_module("student_readCSV_header", "readCSV.py")
    get_petnames = require_function(module, "get_petnames")
    with tempfile.TemporaryDirectory() as temp_dir:
        path = os.path.join(temp_dir, "pets.csv")
        with open(path, "w", encoding="utf-8", newline="") as sample:
            sample.write("Animal ID,Name,Animal Type\n")
        check_equal(get_petnames(path), ["Name"])


def load_count_names():
    if HERE not in sys.path:
        sys.path.insert(0, HERE)
    return load_module("student_countNames", "countNames.py", skip_main_call=True)


def test_petname_frequencies_repeated_names():
    module = load_count_names()
    frequencies = require_function(module, "petname_frequencies")
    check_equal(
        frequencies(["Luna", "Milo", "Luna", "luna", "Milo", "Luna"]),
        {"Luna": 3, "Milo": 2, "luna": 1},
    )


def test_petname_frequencies_single_name():
    module = load_count_names()
    frequencies = require_function(module, "petname_frequencies")
    check_equal(frequencies(["Pepper"]), {"Pepper": 1})


def test_normalize_name_case_and_spaces():
    module = load_count_names()
    normalize = require_function(module, "normalize_name")
    check_equal(normalize("  MiXeD Case  "), "mixedcase")


def test_normalize_name_punctuation():
    module = load_count_names()
    normalize = require_function(module, "normalize_name")
    examples = {
        "  *Jo-Jo, Jr.  ": "jojojr",
        "O'Malley": "omalley",
        "SIR_BARKS!": "sirbarks",
    }
    for original, expected in examples.items():
        actual = normalize(original)
        if actual != expected:
            raise TestFailure(
                "normalize_name({!r}) should return {!r}, not {!r}.".format(
                    original, expected, actual
                )
            )


def test_normalize_name_preserves_digits():
    module = load_count_names()
    normalize = require_function(module, "normalize_name")
    check_equal(normalize("R2-D2"), "r2d2")


def test_normalize_names_combines_variants():
    module = load_count_names()
    normalize_names = require_function(module, "normalize_names")
    original = {
        "*Luna": 2,
        "LUNA": 3,
        "Lu-na": 4,
        "Milo": 1,
        "Mi lo": 2,
    }
    check_equal(normalize_names(original), {"luna": 9, "milo": 3})


def test_normalize_names_keeps_distinct_names():
    module = load_count_names()
    normalize_names = require_function(module, "normalize_names")
    check_equal(
        normalize_names({"Luna": 4, "Milo": 2, "Pepper": 7}),
        {"luna": 4, "milo": 2, "pepper": 7},
    )


def test_count_names_main():
    module = load_count_names()
    main = require_function(module, "main")

    # Distinct counts make the required descending order unambiguous.
    names = []
    expected = []
    for rank in range(12, 0, -1):
        name = "pet{}".format(rank)
        names.extend([name] * rank)
        if len(expected) < 10:
            expected.append((name, rank))

    calls = []
    original_get_petnames = module.readCSV.get_petnames

    def fake_get_petnames(path):
        calls.append(path)
        return names

    module.readCSV.get_petnames = fake_get_petnames
    output = io.StringIO()
    try:
        with contextlib.redirect_stdout(output):
            main()
    finally:
        module.readCSV.get_petnames = original_get_petnames

    check(
        len(calls) == 1,
        "main() should call readCSV.get_petnames() exactly once.",
    )
    check(
        os.path.basename(os.path.normpath(str(calls[0]))) == "animals.csv",
        "main() should read pet names from animals.csv.",
    )

    lines = [line.strip() for line in output.getvalue().splitlines() if line.strip()]
    check(
        len(lines) == 10,
        "main() should print exactly 10 non-empty lines; it printed {}.".format(
            len(lines)
        ),
    )
    for line, (name, count) in zip(lines, expected):
        check(
            re.search(r"\b{}\b".format(re.escape(name)), line)
            and re.search(r"\b{}\b".format(count), line),
            "Expected the next output line to show {} with count {}, but got {!r}.".format(
                name, count, line
            ),
        )


QUESTIONS = {
    "q1": (
        "readText.py",
        [
            ("reads lines and preserves line endings", test_read_text_lines),
            ("handles one-line and empty files", test_read_text_small_files),
        ],
    ),
    "q2": (
        "readCSV.py",
        [
            ("reads names with the csv module's quoting rules", test_csv_names),
            ("handles a header-only CSV file", test_csv_header_only),
        ],
    ),
    "q3": (
        "countNames.py",
        [
            ("counts repeated names case-sensitively", test_petname_frequencies_repeated_names),
            ("counts a name that appears once", test_petname_frequencies_single_name),
            ("normalizes capitalization and spaces", test_normalize_name_case_and_spaces),
            ("removes punctuation from names", test_normalize_name_punctuation),
            ("preserves digits while normalizing", test_normalize_name_preserves_digits),
            ("combines counts for normalized variants", test_normalize_names_combines_variants),
            ("keeps distinct normalized names and counts", test_normalize_names_keeps_distinct_names),
            ("prints the ten most common names", test_count_names_main),
        ],
    ),
}

FILE_TO_QUESTION = {
    filename: question for question, (filename, tests) in QUESTIONS.items()
}


def run_question(question):
    filename, tests = QUESTIONS[question]
    print("\n{}: {}".format(question, filename))
    passed = 0
    for description, test in tests:
        try:
            test()
        except Exception as error:
            print("  FAIL  {}".format(description))
            if isinstance(error, TestFailure):
                message = str(error)
            else:
                message = "{}: {}".format(type(error).__name__, error)
            for line in message.splitlines() or ["No error message was provided."]:
                print("        {}".format(line))
        else:
            passed += 1
            print("  PASS  {}".format(description))
    print("  Result: {}/{} tests passed".format(passed, len(tests)))
    return passed, len(tests)


def parse_args():
    parser = argparse.ArgumentParser(
        description="Run the public tests for the Homework 1 Python refresher."
    )
    parser.add_argument(
        "filename",
        nargs="?",
        choices=sorted(FILE_TO_QUESTION),
        help="student file to test (omit to test all three files)",
    )
    return parser.parse_args()


def main():
    args = parse_args()
    selected_question = FILE_TO_QUESTION.get(args.filename)
    questions = [selected_question] if selected_question else sorted(QUESTIONS)
    passed = 0
    total = 0
    for question in questions:
        question_passed, question_total = run_question(question)
        passed += question_passed
        total += question_total

    print("\nOverall: {}/{} tests passed.".format(passed, total))
    if passed == total:
        print("All selected tests passed!")
        return 0
    print("Some tests failed. Read the messages above, update your code, and try again.")
    return 1


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