"""Проверка практикума: символы и слова. Без интернета."""
import base64
import json
import os
import re
import subprocess
import sys
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, font as tkfont

# Шкала: сколько заданий сдано -> отметка. Три программы: первый урок пары
# посвящена обработке символов и границ слов.
SCALE = [(3, "5"), (2, "4"), (1, "3")]
# Что подсказать, если программа не уложилась во время: у каждой задачи своя беда.
SLOW_HINTS = {1: 'Считай только точные совпадения: регистр и пробелы сохраняются.', 2: 'Новое слово начинается при переходе от разделителя к непробельному символу.', 3: 'Не забудь обработать последнее слово; добавленный пробел завершает его.'}
RECURSION_NOTE = ("стек переполнился (RecursionError): у рекурсивной функции нет базы "
                  "или шаг не ведёт к ней")
TIMEOUT = 5          # секунд на один запуск программы ученика
TESTS_PER_TASK = 9

# ==== ЭТАЛОН: вставлено gen.py, руками не править ====
ETALON_B64 = "IiIi0KLQtdGB0YLRiyDRgtGA0ZHRhSDQv9GA0L7Qs9GA0LDQvNC8INCw0L3QsNC70LjQt9CwINGB0YLRgNC+0Lo7INGN0YLQsNC70L7QvdGLINGB0LrRgNGL0YLRiyDQsiDQvtCx0L7Qu9C+0YfQutC1INC/0YDQvtCy0LXRgNC60LguIiIiCmltcG9ydCByYW5kb20KCmRlZiBnZW4ocm5nKToKICAgIHJldHVybiAnJy5qb2luKHJuZy5jaG9pY2UoJ9Cw0LDQsdCyINCQ0JF4eXowMDEyOSBcdCcpIGZvciBfIGluIHJhbmdlKHJuZy5yYW5kaW50KDAsMTAwKSkpCgpkZWYgZ2VuMShybmcpOnJldHVybiBnZW4ocm5nKSsnXG4nK3JuZy5jaG9pY2UoJ9Cw0JB4MDkgJykrJ1xuJwpkZWYgZ2VuMihybmcpOnJldHVybiBnZW4ocm5nKSsnXG4nCmRlZiBnZW4zKHJuZyk6cmV0dXJuIGdlbihybmcpKydcbicrc3RyKHJuZy5yYW5kaW50KDEsMTIpKSsnXG4nCmRlZiBzb2x2ZTEoZGF0YSk6CiAgICBzLGM9ZGF0YS5zcGxpdCgnXG4nKVs6Ml0KICAgIHJldHVybiBzdHIocy5jb3VudChjKSkKZGVmIHNvbHZlMihkYXRhKTpyZXR1cm4gc3RyKGxlbihkYXRhLnNwbGl0KCdcbicpWzBdLnNwbGl0KCkpKQpkZWYgc29sdmUzKGRhdGEpOgogICAgcyxrPWRhdGEuc3BsaXQoJ1xuJylbOjJdCiAgICByZXR1cm4gc3RyKHN1bShsZW4odyk9PWludChrKSBmb3IgdyBpbiBzLnNwbGl0KCkpKQpUQVNLUz17MTooJ9Cn0LDRgdGC0L7RgtCwINGB0LjQvNCy0L7Qu9CwJyxnZW4xLHNvbHZlMSksMjooJ9Ca0L7Qu9C40YfQtdGB0YLQstC+INGB0LvQvtCyJyxnZW4yLHNvbHZlMiksMzooJ9Ch0LvQvtCy0LAg0LfQsNC00LDQvdC90L7QuSDQtNC70LjQvdGLJyxnZW4zLHNvbHZlMyl9ClBMQU49e307SlVER0U9e307TU9EVUxFUz17fTtNT0RGVU5DUz17fTtVU0VTPXt9O0ZVTkNTPXt9O1JFQ1VSU0lWRT17fTtMT09TRV9TVFI9c2V0KCk7QkFOTkVEPXt9O1RPTD17MTowLDI6MCwzOjB9O0RFQ0lNQUxTPXsxOjAsMjowLDM6MH0KSElOVFM9ezE6J9Ch0YfQuNGC0LDQuSDRgtC+0LvRjNC60L4g0YLQvtGH0L3Ri9C1INGB0L7QstC/0LDQtNC10L3QuNGPOiDRgNC10LPQuNGB0YLRgCDQuCDQv9GA0L7QsdC10LvRiyDRgdC+0YXRgNCw0L3Rj9GO0YLRgdGPLicsMjon0J3QvtCy0L7QtSDRgdC70L7QstC+INC90LDRh9C40L3QsNC10YLRgdGPINC/0YDQuCDQv9C10YDQtdGF0L7QtNC1INC+0YIg0YDQsNC30LTQtdC70LjRgtC10LvRjyDQuiDQvdC10L/RgNC+0LHQtdC70YzQvdC+0LzRgyDRgdC40LzQstC+0LvRgy4nLDM6J9Cd0LUg0LfQsNCx0YPQtNGMINC+0LHRgNCw0LHQvtGC0LDRgtGMINC/0L7RgdC70LXQtNC90LXQtSDRgdC70L7QstC+OyDQtNC+0LHQsNCy0LvQtdC90L3Ri9C5INC/0YDQvtCx0LXQuyDQt9Cw0LLQtdGA0YjQsNC10YIg0LXQs9C+Lid9Ck1VU1Q9ezE6WydcbtCwXG4nLCfQsNCw0LBcbtCwXG4nLCfQkNCw0JBcbtCwXG4nLCcgICBcbiBcbicsJ3h5elxu0LBcbiddLDI6WydcbicsJyAgIFxuJywn0YHQu9C+0LLQvlxuJywnICDQtNCy0LAgICDRgdC70L7QstCwICBcbicsJ9CwXHTQsSAg0LJcbicsJ9C00L7QvFx20LvQtdGBXG4nXSwzOlsnXG4xXG4nLCfQsFxuMVxuJywnICDQtNC+0Lwg0LrQvtGCICAg0LvQtdGBICBcbjNcbicsJ9C00L7QvFx00LhcdNC70LXRgVxuM1xuJywn0L7QtNC40L3QtNC70LjQvdC90YvQuVxuMlxuJywn0LTQvtC8XHUyMDAz0LvQtdGBXG4zXG4nXX0KTVVTVFsxXS5hcHBlbmQoJ9CwJyoxMDAwKydcbtCwXG4nKQpNVVNUWzJdLmFwcGVuZCgnYSAnKjUwMCsnXG4nKQpNVVNUWzNdLmFwcGVuZCgn0LTQvtC8ICcqMjUwKydcbjNcbicpCmRlZiB0ZXN0cyhudW0sY291bnQ9MTIsc2VlZD1Ob25lKToKICAgIHJuZz1yYW5kb20uUmFuZG9tKHNlZWQpO2Nhc2VzPU1VU1RbbnVtXVs6XQogICAgd2hpbGUgbGVuKGNhc2VzKTxjb3VudDoKICAgICAgICBkPVRBU0tTW251bV1bMV0ocm5nKQogICAgICAgIGlmIGQgbm90IGluIGNhc2VzOmNhc2VzLmFwcGVuZChkKQogICAgcmV0dXJuIFsoZCxUQVNLU1tudW1dWzJdKGQpKSBmb3IgZCBpbiBjYXNlc10K"
# ==== /ЭТАЛОН ====

_ns: dict = {}
exec(base64.b64decode(ETALON_B64).decode("utf-8"), _ns)
TASKS, tests = _ns["TASKS"], _ns["tests"]
PLAN, JUDGE, HINTS = _ns["PLAN"], _ns["JUDGE"], _ns["HINTS"]
FUNCS, BANNED = _ns["FUNCS"], _ns["BANNED"]
RECURSIVE, LOOSE_STR = _ns["RECURSIVE"], _ns["LOOSE_STR"]
MODULES, MODFUNCS, USES = _ns["MODULES"], _ns["MODFUNCS"], _ns["USES"]
TOL, DECIMALS = _ns["TOL"], _ns["DECIMALS"]

NUM = re.compile(r"-?\d+(?:[.,]\d+)?")


def split_line(s):
    """Строка -> (скелет без чисел, список чисел). Регистр и пробелы не важны."""
    s = re.sub(r"\s+", " ", s.strip()).lower().replace("ё", "е")
    nums = [float(m.group().replace(",", ".")) for m in NUM.finditer(s)]
    return NUM.sub("#", s), nums


def decimals_of(s):
    """Сколько знаков после точки у каждого числа в строке."""
    out = []
    for m in NUM.finditer(s):
        t = m.group().replace(",", ".")
        out.append(len(t.split(".")[1]) if "." in t else 0)
    return out


PROMPT_NOTE = ("похоже, у тебя input() с текстом-приглашением: «input(\"Введите...\")». "
               "Убери текст из скобок — вывод программы читает машина")


def strip_prompt(a, skel_e):
    """Отрезает приглашение input(), напечатанное перед ответом в той же строке."""
    for i in range(1, len(a)):
        if split_line(a[i:])[0] == skel_e:
            return a[i:]
    return None


def compare(expected, actual, need_decimals=None, tol=0.005):
    """(принято, замечание). Замечание — то, что стоит сказать ученику."""
    exp = [l for l in expected.strip().splitlines() if l.strip()]
    act = [l for l in actual.strip().splitlines() if l.strip()]
    note = ""
    if len(act) > len(exp):
        act, note = act[-len(exp):], PROMPT_NOTE
    if len(act) != len(exp):
        return False, "программа вывела %d строк(и), а нужно %d" % (len(act), len(exp))
    for e, a in zip(exp, act):
        skel_e, nums_e = split_line(e)
        skel_a, nums_a = split_line(a)
        if skel_e != skel_a:
            cut = strip_prompt(a, skel_e)
            if cut is None:
                return False, ""
            a, note = cut, PROMPT_NOTE
            skel_a, nums_a = split_line(a)
        if len(nums_e) != len(nums_a):
            return False, ""
        for x, y in zip(nums_e, nums_a):
            if abs(x - y) > tol + 1e-9:
                return False, ""
        if need_decimals is not None:
            want = [d for d in decimals_of(e) if d]
            got = [d for d in decimals_of(a) if d] or [0]
            if want and got[-1] != want[-1]:
                return False, ("число посчитано верно, но выведено как «%s», "
                               "а нужно ровно %d знака после точки — f\"{x:.%df}\""
                               % (a.strip(), need_decimals, need_decimals))
    return True, note


def interpreter():
    """Каким питоном запускать решения ученика.

    Оболочку могут открыть через pythonw (двойной щелчок по .bat, ярлык):
    у него нет консоли, и вывод дочерней программы можно не получить вовсе.
    Поэтому рядом с pythonw.exe ищем обычный python.exe.
    """
    exe = Path(sys.executable)
    if exe.name.lower() == "pythonw.exe":
        plain = exe.with_name("python.exe")
        if plain.exists():
            return str(plain)
    return str(exe)


PYTHON = interpreter()
# Windows: не давать мелькать чёрным окошкам — запусков десятки подряд
NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)


def run(path, data):
    env = dict(os.environ, PYTHONIOENCODING="utf-8", PYTHONUTF8="1")
    try:
        p = subprocess.run([PYTHON, str(path)], input=data,
                           capture_output=True, text=True, encoding="utf-8",
                           errors="replace", timeout=TIMEOUT, env=env,
                           creationflags=NO_WINDOW,
                           cwd=str(Path(path).parent))
    except subprocess.TimeoutExpired:
        return None, "программа не завершилась за %d секунд" % TIMEOUT
    if p.returncode != 0:
        err = [l for l in (p.stderr or "").strip().splitlines() if l.strip()]
        return None, "ошибка при запуске: " + (err[-1] if err else "код %d" % p.returncode)
    return p.stdout, ""


def shown(data):
    return data.strip().replace("\n", " ⏎ ")


def check_judged(num, path):
    """Задание без ключа: несколько прогонов на набор, вердикт выносит судья."""
    log = []
    suite = tests(num, TESTS_PER_TASK)
    for data, _ in suite:
        outs = []
        for d in PLAN[num](data):
            got, err = run(path, d)
            if got is None:
                log.append("  на входе «%s» — %s" % (shown(d), err))
                return False, log
            outs.append(got)
        ok, why = JUDGE[num](data, outs)
        if not ok:
            log.append("  на входе «%s»" % shown(data))
            log.append("     твоя программа вывела: %s"
                       % (" ⏎ ".join(outs[0].strip().splitlines()) or "(ничего)"))
            log.append("     " + why)
            return False, log
    log.insert(0, "  прошло %d наборов данных из %d" % (len(suite), len(suite)))
    return True, log


# ---------------------------------------------------------------- документация
DOC_REQUIRED = set()   # обязательной шапки в этой паре нет: она по желанию
ARROW = re.compile(r"->|→|=>")
INT = re.compile(r"-?\d+")


def read_source(path):
    for enc in ("utf-8-sig", "cp1251"):
        try:
            return Path(path).read_text(encoding=enc)
        except (UnicodeDecodeError, OSError):
            continue
    return ""


def doc_lines(text):
    """Строки-комментарии: {'ввод': текст, 'вывод': ..., 'пример': ..., 'инвариант': ...}"""
    found = {}
    for raw in text.splitlines():
        line = raw.strip()
        if not line.startswith("#"):
            continue
        body = line.lstrip("#").strip()
        low = body.lower().replace("ё", "е")
        for key in ("ввод", "вывод", "пример"):
            if low.startswith(key) and ":" in body and key not in found:
                found[key] = body.split(":", 1)[1].strip()
        if "инвариант" in low and "инвариант" not in found:
            found["инвариант"] = body
    return found


def check_doc(num, path):
    """Шапка программы. -> (принято, строки отчёта). Пример проверяется запуском."""
    doc = doc_lines(read_source(path))
    log = []
    if num in DOC_REQUIRED:
        miss = [name for key, name in (("ввод", "# Ввод: ..."), ("вывод", "# Вывод: ..."),
                                       ("пример", "# Пример: вход -> вывод"),
                                       ("инвариант", "# Инвариант: ..."))
                if not doc.get(key)]
        if miss:
            log.append("  программа считает верно, но нет документации: "
                       + ", ".join(miss))
            log.append("     подсказка: в задании 3 шапка обязательна — что на входе, "
                       "что на выходе, пример и фраза-инвариант перед циклом")
            return False, log
    ex = doc.get("пример")
    if not ex:
        return True, log
    parts = ARROW.split(ex)
    if len(parts) != 2:
        log.append("  в шапке пример «%s» — не видно стрелки ->: слева вход, справа вывод" % ex)
        return False, log
    feed = INT.findall(parts[0])
    want = INT.findall(parts[1])
    if not feed or not want:
        log.append("  в шапке пример «%s» — слева и справа от -> должны быть числа" % ex)
        return False, log
    got, err = run(path, "\n".join(feed) + "\n")
    if got is None:
        log.append("  на входе из примера «%s» — %s" % (" ".join(feed), err))
        return False, log
    have = INT.findall(got)
    if have != want:
        log.append("  шапка обещает: %s -> %s" % (" ".join(feed), " ".join(want)))
        log.append("     а программа на этом входе печатает: %s"
                   % (" ".join(have) or "(ничего)"))
        log.append("     подсказка: документация обязана быть правдой — исправь "
                   "пример или программу")
        return False, log
    log.append("  шапка: пример «%s -> %s» проверен запуском" % (" ".join(feed), " ".join(want)))
    return True, log


# ------------------------------------------------------------ договор функции
# Запускается отдельным процессом (зависшая функция не вешает оболочку): из
# файла ученика берутся только def и import — ввод и вывод программы не
# выполняются, — и функции зовутся напрямую. На выходе JSON: что вернула
# каждая и что успела напечатать.
HARNESS = r'''
import ast, contextlib, io, json, sys
path = sys.argv[1]
src = None
for enc in ("utf-8-sig", "cp1251"):
    try:
        src = open(path, encoding=enc).read()
        break
    except UnicodeDecodeError:
        pass
tree = ast.parse(src)
keep = [n for n in tree.body
        if isinstance(n, (ast.FunctionDef, ast.Import, ast.ImportFrom))]
ns = {"__name__": "proverka"}
exec(compile(ast.Module(body=keep, type_ignores=[]), path, "exec"), ns)
out = []
for name, args in json.loads(sys.stdin.read()):
    f = ns.get(name)
    if not callable(f):
        out.append({"err": "nofunc"})
        continue
    buf = io.StringIO()
    try:
        with contextlib.redirect_stdout(buf):
            r = f(*args)
    except Exception as e:
        out.append({"err": "%s: %s" % (type(e).__name__, e)})
        continue
    if not (r is None or isinstance(r, (bool, int, float))):
        r = repr(r)
    out.append({"r": r, "p": buf.getvalue()})
print(json.dumps(out))
'''


def show_call(name, args):
    return "%s(%s)" % (name, ", ".join(str(a) for a in args))


def pyrepr(v):
    return "None" if v is None else str(v)


def check_funcs(num, path):
    """Функции задания зовутся напрямую. -> (выполнен договор, строки отчёта)"""
    return check_contract(FUNCS.get(num, []), path, "программа считает верно, но функции")


def check_contract(funcs, path, missing):
    """Каждая функция договора зовётся напрямую из файла path."""
    log = []
    for name, nargs, cases, solve in funcs:
        batch = [[name, list(a)] for a in cases]
        env = dict(os.environ, PYTHONIOENCODING="utf-8", PYTHONUTF8="1")
        try:
            p = subprocess.run([PYTHON, "-c", HARNESS, str(path)], input=json.dumps(batch),
                               capture_output=True, text=True, encoding="utf-8",
                               errors="replace", timeout=TIMEOUT * 2, env=env,
                               creationflags=NO_WINDOW, cwd=str(Path(path).parent))
        except subprocess.TimeoutExpired:
            log.append("  функция %s не закончила работу за %d секунд"
                       % (name, TIMEOUT * 2))
            if num in SLOW_HINTS:
                log.append("     подсказка: " + SLOW_HINTS[num])
            return False, log
        if p.returncode != 0:
            err = [l for l in (p.stderr or "").strip().splitlines() if l.strip()]
            log.append("  файл не удалось разобрать: " + (err[-1] if err else "ошибка"))
            return False, log
        res = json.loads(p.stdout.strip().splitlines()[-1])
        for args, got in zip(cases, res):
            want = solve(*args)
            where = show_call(name, args)
            if got.get("err") == "nofunc":
                log.append("  %s %s в файле %s нет"
                           % (missing, show_call(name, "n" if nargs == 1 else "my"[:nargs]),
                              Path(path).name))
                log.append("     подсказка: задание просит написать её через def "
                           "именно с таким именем — оболочка зовёт её напрямую")
                return False, log
            if "err" in got:
                e = got["err"]
                log.append("  вызов %s падает: %s" % (where, e))
                if "positional argument" in e:
                    log.append("     подсказка: у функции %s должно быть параметров: %d"
                               % (name, nargs))
                elif "RecursionError" in e:
                    log.append("     подсказка: " + RECURSION_NOTE)
                elif "NameError" in e:
                    log.append("     подсказка: функция берёт переменную снаружи — всё, "
                               "что ей нужно, передавай параметрами")
                return False, log
            r = got["r"]
            if name in LOOSE_STR and isinstance(r, str) and r.strip("'\"") == str(want):
                if not any("возвращает строку" in l for l in log):
                    log.append("  замечание: %s возвращает строку «%s», договор просит число — "
                               "int(...) или сборка числом: to_bin(n // 2) * 10 + n %% 2"
                               % (name, r.strip("'\"")))
                continue
            if r is None and got["p"].strip():
                log.append("  вызов %s печатает «%s», но ничего не возвращает"
                           % (where, got["p"].strip().replace("\n", " ")))
                log.append("     подсказка: функция отдаёт результат через return, а "
                           "не print — иначе её нельзя сложить, сравнить, поставить в if")
                return False, log
            if r != want or (isinstance(want, bool) and not isinstance(r, (bool, int))):
                log.append("  вызов %s вернул %s, а должен вернуть %s"
                           % (where, pyrepr(r), pyrepr(want)))
                return False, log
        log.append("  функция %s: %d прямых вызовов, всё вернула верно" % (name, len(cases)))
    return True, log


def check_recursion(num, path):
    """Функции задания рекурсивные: зовут себя (или напарницу) и без циклов."""
    groups = RECURSIVE.get(num)
    if not groups:
        return True, []
    import ast
    try:
        tree = ast.parse(read_source(path))
    except SyntaxError as e:
        return False, ["  файл не удалось разобрать: %s" % e]
    defs = {n.name: n for n in tree.body if isinstance(n, ast.FunctionDef)}
    log = []
    for group in groups:
        for name in group:
            node = defs.get(name)
            if node is None:
                continue          # отсутствие функции объяснит проверка договора
            loops = [n for n in ast.walk(node)
                     if isinstance(n, (ast.For, ast.While, ast.ListComp, ast.GeneratorExp,
                                       ast.SetComp, ast.DictComp))]
            calls = {n.func.id for n in ast.walk(node)
                     if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)}
            mates = [g for g in group if g in calls]
            if loops:
                log.append("  программа считает верно, но в функции %s есть цикл "
                           "(строка %d)" % (name, loops[0].lineno))
                log.append("     подсказка: сегодня функция работает без циклов — "
                           "база и вызов самой себя с меньшим n")
                return False, log
            if not mates:
                who = "себя" if len(group) == 1 else "себя или %s" % " / ".join(
                    g for g in group if g != name)
                log.append("  программа считает верно, но функция %s не рекурсивная: "
                           "она не зовёт %s" % (name, who))
                log.append("     подсказка: готовое (bin, str, sum) сегодня не годится — "
                           "напиши базу и шаг: return %s(... поменьше ...)" % name)
                return False, log
    names = [n for g in groups for n in g]
    log.append("  рекурсия: %s зовут себя, циклов нет" % ", ".join(names))
    return True, log


def check_banned(num, path):
    """Готовое из библиотеки, которое в этом задании надо написать самому."""
    words = BANNED.get(num)
    if not words:
        return True, []
    text = read_source(path)
    body = "\n".join(l.split("#", 1)[0] for l in text.splitlines())
    star = re.search(r"from\s+math\s+import\s+\*", body)
    hit = []
    for w in words:
        dotted = re.search(r"math\s*\.\s*%s\b" % w, body)
        named = re.search(r"from\s+math\s+import\s+[^\n]*\b%s\b" % w, body)
        # звёздочка: беда, только если функцию зовут, а своей с таким именем нет
        starred = star and re.search(r"\b%s\s*\(" % w, body) \
            and not re.search(r"def\s+%s\b" % w, body)
        if dotted or named or starred:
            hit.append(w)
    if not hit:
        return True, []
    return False, ["  программа считает верно, но берёт готовую %s из math"
                   % " и ".join(hit),
                   "     подсказка: сегодня пишем своё — в своей функции"]


# ------------------------------------------------------------------- модули
IMPORT_PROBE = "import sys; sys.path.insert(0, '.'); import %s"


def check_module_files(num, path):
    """Модули задания лежат рядом с программой. Без них прогон бессмыслен."""
    miss = [m for m in MODULES.get(num, []) if not (Path(path).parent / (m + ".py")).exists()]
    if not miss:
        return True, []
    return False, ["  рядом с %s нет модуля %s" % (Path(path).name,
                                                   " и ".join(m + ".py" for m in miss)),
                   "     подсказка: модуль — это файл с функциями. Сохрани его под этим "
                   "именем в ту же папку, что и программу"]


def check_modules(num, path):
    """Каждый модуль: при импорте молчит, функции договора возвращают верное."""
    log = []
    env = dict(os.environ, PYTHONIOENCODING="utf-8", PYTHONUTF8="1")
    for mod in MODULES.get(num, []):
        mpath = Path(path).parent / (mod + ".py")
        try:
            p = subprocess.run([PYTHON, "-c", IMPORT_PROBE % mod], input="",
                               capture_output=True, text=True, encoding="utf-8",
                               errors="replace", timeout=TIMEOUT, env=env,
                               creationflags=NO_WINDOW, cwd=str(mpath.parent))
        except subprocess.TimeoutExpired:
            log.append("  модуль %s.py при импорте не закончил работу за %d секунд"
                       % (mod, TIMEOUT))
            log.append("     подсказка: при импорте выполняется весь файл. Ввод и "
                       "проверки — под if __name__ == \"__main__\":")
            return False, log
        if p.returncode != 0:
            err = [l for l in (p.stderr or "").strip().splitlines() if l.strip()]
            e = err[-1] if err else "код %d" % p.returncode
            log.append("  модуль %s.py не импортируется: %s" % (mod, e))
            if "EOF" in e:
                log.append("     подсказка: в модуле есть input() — при импорте он ждёт "
                           "ввода. Убери его под if __name__ == \"__main__\":")
            return False, log
        if p.stdout.strip():
            log.append("  модуль %s.py при импорте печатает: %s"
                       % (mod, " ⏎ ".join(p.stdout.strip().splitlines()[:3])))
            log.append("     подсказка: свои проверки модуля спрячь под "
                       "if __name__ == \"__main__\": — тогда они работают только при "
                       "запуске самого модуля, а программе не мешают")
            return False, log
        ok, notes = check_contract(MODFUNCS.get(mod, []), mpath,
                                   "функции")
        log += notes
        if not ok:
            return False, log
        log.append("  модуль %s.py: при импорте молчит, функции %s возвращают верное"
                   % (mod, ", ".join(f[0] for f in MODFUNCS.get(mod, []))))
    return True, [l for l in log if "прямых вызовов" not in l]


def check_imports(num, path):
    """Программа берёт функции из модуля, а не определяет их у себя заново."""
    mods = MODULES.get(num, [])
    if not mods:
        return True, []
    import ast
    try:
        tree = ast.parse(read_source(path))
    except SyntaxError as e:
        return False, ["  файл не удалось разобрать: %s" % e]
    own = sorted({n.name for n in ast.walk(tree)
                  if isinstance(n, ast.FunctionDef) and n.name in USES.get(num, [])})
    if own:
        return False, ["  программа считает верно, но функцию %s она пишет сама"
                       % ", ".join(own),
                       "     подсказка: функция уже есть в модуле — возьми её оттуда: "
                       "from %s import %s. Модуль для того и пишется, чтобы не "
                       "переписывать" % (mods[0], ", ".join(own))]
    used = set()
    for n in ast.walk(tree):
        if isinstance(n, ast.Import):
            used |= {a.name for a in n.names}
        elif isinstance(n, ast.ImportFrom) and n.module:
            used.add(n.module)
    miss = [m for m in mods if m not in used]
    if miss:
        return False, ["  программа не подключает модуль %s" % " и ".join(miss),
                       "     подсказка: import %s или from %s import ..."
                       % (miss[0], miss[0])]
    return True, ["  программа берёт функции из %s" % ", ".join(m + ".py" for m in mods)]


def check_task(num, path):
    """Прогон одного задания. -> (сдано, список строк отчёта)"""
    log = []
    for step in (check_module_files, check_modules, check_io, check_imports,
                 check_banned, check_recursion, check_funcs, check_doc):
        ok, notes = step(num, path)
        log += notes
        if not ok:
            return ok, log
    return True, log


def check_io(num, path):
    """Прогон на наборах данных. -> (сдано, список строк отчёта)"""
    if num in JUDGE:
        return check_judged(num, path)
    log = []
    suite = tests(num, TESTS_PER_TASK)
    for data, expected in suite:
        got, err = run(path, data)
        if err and got is None:
            log.append("  на входе «%s» — %s" % (shown(data), err))
            if "не завершилась" in err and num in SLOW_HINTS:
                log.append("     подсказка: " + SLOW_HINTS[num])
            elif "RecursionError" in err:
                log.append("     подсказка: " + RECURSION_NOTE)
            return False, log
        ok, note = compare(expected, got, DECIMALS.get(num), TOL.get(num, 0.005))
        if not ok:
            log.append("  на входе «%s»" % shown(data))
            log.append("     твоя программа вывела: %s"
                       % (" ⏎ ".join(got.strip().splitlines()) or "(ничего)"))
            log.append("     а должно быть:         %s"
                       % " ⏎ ".join(expected.splitlines()))
            if note:
                log.append("     подсказка: " + note)
            elif num in HINTS:
                log.append("     подсказка: " + HINTS[num])
            return False, log
        if note:
            log.append("  замечание: " + note)
    log.insert(0, "  прошло %d наборов данных из %d" % (len(suite), len(suite)))
    return True, log


def find_files():
    """Ищет zadanie1..3.py: рядом со скриптом, в текущей папке и в «Документах»."""
    roots = [Path(__file__).resolve().parent, Path.cwd(),
             Path.home() / "Documents", Path.home() / "Документы"]
    found = {}
    for root in roots:
        if not root.is_dir():
            continue
        for num in TASKS:
            if num in found:
                continue
            hits = sorted(root.glob("zadanie%d.py" % num)) \
                + sorted(root.glob("*/zadanie%d.py" % num)) \
                + sorted(root.glob("*/*/zadanie%d.py" % num))
            if hits:
                found[num] = hits[-1]     # самый свежий путь в глубине
    return found


class App:
    def __init__(self, root):
        self.root, self.paths = root, {}
        root.title("Проверка практикума — символы и слова")
        root.minsize(760, 560)
        base = tkfont.nametofont("TkDefaultFont")
        base.configure(size=11)
        self.mono = tkfont.Font(family="Consolas", size=10)

        tk.Label(root, text="Практикум «Численные методы»", font=("Segoe UI", 15, "bold"),
                 anchor="w").pack(fill="x", padx=16, pady=(14, 0))
        tk.Label(root, anchor="w", justify="left", fg="#5c6070",
                 text="В одну свою папку: zadanie1.py … zadanie3.py. "
                      "Потом «Найти мои файлы» и «Проверить всё».").pack(
            fill="x", padx=16, pady=(2, 12))

        grid = tk.Frame(root)
        grid.pack(fill="x", padx=16)
        self.rows = {}
        for num, (title, _, _) in sorted(TASKS.items()):
            fr = tk.Frame(grid)
            fr.pack(fill="x", pady=3)
            tk.Label(fr, text="%d. %s" % (num, title), width=26, anchor="w",
                     font=("Segoe UI", 11, "bold")).pack(side="left")
            lbl = tk.Label(fr, text="файл не выбран", anchor="w", fg="#5c6070", width=34)
            lbl.pack(side="left")
            tk.Button(fr, text="Выбрать…", command=lambda n=num: self.pick(n)).pack(side="left")
            st = tk.Label(fr, text="", width=14, anchor="w", font=("Segoe UI", 11, "bold"))
            st.pack(side="left", padx=(10, 0))
            self.rows[num] = (lbl, st)

        bar = tk.Frame(root)
        bar.pack(fill="x", padx=16, pady=12)
        tk.Button(bar, text="Найти мои файлы", command=self.autofind).pack(side="left")
        tk.Button(bar, text="Проверить всё", command=self.check_all,
                  font=("Segoe UI", 11, "bold")).pack(side="left", padx=8)
        self.mark = tk.Label(bar, text="—", font=("Segoe UI", 26, "bold"), width=3)
        self.mark.pack(side="right")
        tk.Label(bar, text="Отметка", fg="#5c6070").pack(side="right", padx=(0, 8))
        self.score = tk.Label(bar, text="", fg="#5c6070")
        self.score.pack(side="right", padx=12)

        box = tk.Frame(root)
        box.pack(fill="both", expand=True, padx=16, pady=(0, 14))
        self.out = tk.Text(box, wrap="word", font=self.mono, height=16,
                           bg="#f6f7fb", relief="solid", bd=1)
        sb = tk.Scrollbar(box, command=self.out.yview)
        self.out.configure(yscrollcommand=sb.set)
        sb.pack(side="right", fill="y")
        self.out.pack(side="left", fill="both", expand=True)
        for tag, color in (("ok", "#137a41"), ("err", "#b3261e"), ("hi", "#8a4b00")):
            self.out.tag_configure(tag, foreground=color)
        self.say("Готово к проверке. Файлы ещё не выбраны.\n")
        self.autofind()

    def say(self, text, tag=None):
        self.out.insert("end", text, tag or ())
        self.out.see("end")
        self.root.update()

    def set_path(self, num, path):
        self.paths[num] = Path(path)
        self.rows[num][0].configure(text=Path(path).name, fg="#1b1b1f")

    def pick(self, num):
        p = filedialog.askopenfilename(title="Задание %d — выбери свой файл" % num,
                                       filetypes=[("Программы Python", "*.py")])
        if p:
            self.set_path(num, p)

    def autofind(self):
        found = find_files()
        for num, p in found.items():
            self.set_path(num, p)
        self.say("Найдено файлов: %d из %d.\n" % (len(found), len(TASKS)),
                 "ok" if len(found) == len(TASKS) else "hi")
        if found:
            self.say("Папка: %s\n" % list(found.values())[0].parent)

    def check_all(self):
        self.out.delete("1.0", "end")
        done = 0
        for num, (title, _, _) in sorted(TASKS.items()):
            lbl, st = self.rows[num]
            if num not in self.paths:
                st.configure(text="нет файла", fg="#8a4b00")
                self.say("%d. %s — файла нет\n" % (num, title), "hi")
                continue
            st.configure(text="проверяю…", fg="#5c6070")
            self.root.update()
            ok, log = check_task(num, self.paths[num])
            st.configure(text="принято" if ok else "не принято",
                         fg="#137a41" if ok else "#b3261e")
            self.say("%d. %s — %s\n" % (num, title, "ПРИНЯТО" if ok else "НЕ ПРИНЯТО"),
                     "ok" if ok else "err")
            for line in log:
                self.say(line + "\n")
            self.say("\n")
            done += ok
        mark = next((m for need, m in SCALE if done >= need), "—")
        self.mark.configure(text=mark,
                            fg={"5": "#137a41", "4": "#1f5fa8",
                                "3": "#8a5a00"}.get(mark, "#b3261e"))
        self.score.configure(text="сдано %d из %d" % (done, len(TASKS)))
        self.say("Итог: сдано %d из %d, отметка %s\n" % (done, len(TASKS), mark), "hi")
        self.say("Покажи этот экран учителю.\n")


if __name__ == "__main__":
    # Запуск идёт через «Проверка.bat» и pythonw — консоли нет, и упавшая
    # оболочка исчезла бы молча. Поэтому ошибку показываем окном.
    try:
        root = tk.Tk()
        App(root)
        root.mainloop()
    except Exception:
        import traceback
        from tkinter import messagebox
        try:
            tk.Tk().withdraw()
        except Exception:
            pass
        messagebox.showerror("Проверка не запустилась",
                             "Покажи это учителю:\n\n" + traceback.format_exc())
        raise
