#!/usr/bin/env bash
# course — тренажёр go-стажировки. Ставится в образ ВМ как /usr/local/bin/course.
# Материал разбит на страницы, проверка многофакторная.

set -uo pipefail

COURSE_ROOT="${COURSE_ROOT:-/opt/course}"
COURSE_HOME="${COURSE_HOME:-$HOME/.course}"
export COURSE_ROOT COURSE_HOME

# Без локали less принимает страницы с кириллицей за двоичные файлы
# и пугает вопросом «may be a binary file», а журнал команд режет
# многобайтные символы. Если локаль не задана — берём безопасную.
case "${LANG:-}" in
    *[Uu][Tt][Ff]*) ;;
    *) LANG=C.UTF-8; export LANG ;;
esac

# shellcheck source=lib/common.sh
. "$COURSE_ROOT/lib/common.sh"

PAGER_CMD="${PAGER:-less -R}"

die() { printf '%s\n' "$*" >&2; exit 1; }

# ---------- поиск дня ----------

day_dir() {
    local nn="$1" d
    d="$(find "$COURSE_ROOT/blocks" -maxdepth 2 -type d -name "day$nn" 2>/dev/null | head -n1)"
    [ -n "$d" ] || die "День $nn не найден. Список: course status"
    printf '%s' "$d"
}

all_days() { find "$COURSE_ROOT/blocks" -maxdepth 2 -type d -name 'day*' | sort; }

current_day() {
    local d nn
    for d in $(all_days); do
        nn="${d##*/day}"
        state_is_passed "day$nn" || { printf '%s' "$nn"; return; }
    done
    printf '%s' "$(all_days | tail -n1 | sed 's/.*day//')"
}

# Последний день, который открывали руками. Держится отдельно от прогресса:
# «первый непройденный» — плохой ориентир, когда день уже сдан, а страницы
# ещё дочитываются, или когда предыдущие дни проходились на другой машине.
last_day_get() { cat "$COURSE_HOME/lastday" 2>/dev/null; }
last_day_set() { printf '%s' "$1" > "$COURSE_HOME/lastday" 2>/dev/null || true; }

resolve_day() {
    local nn="${1:-}"
    [ -n "$nn" ] || nn="$(last_day_get)"
    [ -n "$nn" ] || nn="$(current_day)"
    printf '%02d' "$((10#$nn))" 2>/dev/null || printf '%s' "$nn"
}

day_title() {
    local d="$1" f
    [ -f "$d/title" ] && { head -n1 "$d/title"; return; }
    f="$(ls -1 "$d/pages"/*.md 2>/dev/null | head -1)"
    [ -n "$f" ] || f="$d/material.md"
    head -n1 "$f" 2>/dev/null | sed 's/^#* *//'
}

page_file() { ls -1 "$1/pages"/*.md 2>/dev/null | sed -n "${2}p"; }
page_title() { head -n1 "$1" 2>/dev/null | sed 's/^#* *//'; }

# md_term <файл> — страница для терминала: у каждого блока кода строка-подпись
# по типу (AUTHORING §3): что набирать, что только пример, что вывод.
# Содержимое блока не трогается — рамки слева нет, чтобы команду можно было
# скопировать из терминала без лишних символов.
md_term() {
    local cr="" cf="" cd="" c0=""
    if [ "${2:-}" = color ]; then
        cr=$'\033[1;32m'; cf=$'\033[1;33m'; cd=$'\033[2m'; c0=$'\033[0m'
    fi
    awk -v CR="$cr" -v CF="$cf" -v CD="$cd" -v C0="$c0" '
        {
            line = $0; pre = ""; rest = line
            if (match(line, /^> ?/)) { pre = substr(line, 1, RLENGTH); rest = substr(line, RLENGTH + 1) }
            sub(/^[ \t]+/, "", rest)
            if (!inb && rest ~ /^```/) {
                info = substr(rest, 4); sub(/^[ \t]+/, "", info); sub(/[ \t]+$/, "", info)
                n = split(info, a, /[ \t]+/); kind = (n > 0) ? a[1] : ""; file = ""; at = ""
                for (k = 2; k <= n; k++) {
                    if (a[k] ~ /^file=/) file = substr(a[k], 6)
                    else if (a[k] ~ /^at=/) at = substr(a[k], 4)
                }
                if (kind == "run" && at == "dlv")
                                            lab = CR "▶ ВЫПОЛНИТЕ В ОТЛАДЧИКЕ — по одной команде в ответ на (dlv), не в оболочку" C0
                else if (kind == "run" && at == "stdin")
                                            lab = CR "▶ НАБЕРИТЕ В ЗАПУЩЕННУЮ ПРОГРАММУ — по строке, это её ввод" C0
                else if (kind == "run")     lab = CR "▶ ВЫПОЛНИТЕ" C0
                else if (kind == "demo")    lab = CD "ПРИМЕР — только посмотреть, набирать не нужно" C0
                else if (kind == "output")  lab = CD "ВЫВОД — это печатает программа, набирать не нужно" C0
                else if (file != "")        lab = CF "✎ НАБЕРИТЕ В ФАЙЛ " file C0
                else if (kind == "go")      lab = CD "КОД ДЛЯ ЧТЕНИЯ — набирать не нужно" C0
                else if (kind == "text")    lab = CD "ОБРАЗЕЦ — набирать не нужно" C0
                else                        lab = ""
                print pre "── " lab (lab == "" ? "" : " ──")
                inb = 1; next
            }
            if (inb && rest ~ /^```[ \t]*$/) { print pre "──"; inb = 0; next }
            # вне блоков кода экранированная черта \| — это просто черта:
            # так вертикальную черту пишут внутри таблиц markdown.
            if (!inb) gsub(/\\\|/, "|", line)
            print line
        }' "$1"
}

show_file() {
    [ -f "$1" ] || die "Файл не найден: $1"
    case "$1" in
        *.md)
            if [ -t 1 ]; then md_term "$1" color | $PAGER_CMD
            else md_term "$1" | $PAGER_CMD
            fi ;;
        *)  $PAGER_CMD "$1" ;;
    esac
}

run_setup() {
    local dir="$1" nn="$2" tmp
    # Через bash, а не напрямую: бит x теряется при выкладке сырым tar,
    # и стенд тогда молча не создавался.
    if [ -f "$dir/setup.sh" ]; then
        CURRENT_DAY="day$nn" CURRENT_DAY_DIR="$dir" bash "$dir/setup.sh" >/dev/null 2>&1
    elif [ -f "$dir/setup.sh.enc" ]; then
        # В поставке setup.sh обфусцирован: в нём эталоны заданий (например,
        # строки задачи Парсонса в правильном порядке). Расшифровываем в
        # временный файл и запускаем оттуда, каталог дня передаём переменной.
        tmp="$(mktemp)" || return 0
        obf_decode < "$dir/setup.sh.enc" > "$tmp"
        CURRENT_DAY="day$nn" CURRENT_DAY_DIR="$dir" bash "$tmp" >/dev/null 2>&1
        rm -f "$tmp"
    fi
    return 0
}

# ---------- страницы ----------

cur_page_get() { awk -v d="$1" '$1==d {p=$2} END {print p+0}' "$COURSE_HOME/curpage" 2>/dev/null; }
cur_page_set() {
    grep -v "^$1 " "$COURSE_HOME/curpage" > "$COURSE_HOME/curpage.tmp" 2>/dev/null || true
    mv -f "$COURSE_HOME/curpage.tmp" "$COURSE_HOME/curpage" 2>/dev/null || true
    printf '%s %s\n' "$1" "$2" >> "$COURSE_HOME/curpage"
}

cmd_toc() {
    local nn dir n i f
    nn="$(resolve_day "${1:-}")"; dir="$(day_dir "$nn")"
    n="$(pages_count "$dir")"
    if [ "$n" -eq 0 ]; then
        printf '\n%sДень %s — %s%s\n' "$C_B" "$nn" "$(day_title "$dir")" "$C_0"
        printf '%sМатериал этого дня пока одной страницей.%s\n\n' "$C_DIM" "$C_0"
        printf '  Читать:    %scourse start %s%s\n' "$C_B" "$nn" "$C_0"
        printf '  Задания:   course lab %s\n\n' "$nn"
        return 0
    fi
    printf '\n%sДень %s — %s%s\n' "$C_B" "$nn" "$(day_title "$dir")" "$C_0"
    printf '%sстраниц: %s%s\n\n' "$C_DIM" "$n" "$C_0"
    i=1
    while [ "$i" -le "$n" ]; do
        f="$(page_file "$dir" "$i")"
        if grep -q "^day$nn $(printf '%02d' "$i") " "$PAGES_FILE" 2>/dev/null; then
            printf '  %s✔%s  %2d. %s\n' "$C_OK" "$C_0" "$i" "$(page_title "$f")"
        else
            printf '  %s·%s  %2d. %s\n' "$C_DIM" "$C_0" "$i" "$(page_title "$f")"
        fi
        i=$((i + 1))
    done
    echo
    printf '  Читать:      %scourse start %s%s\n' "$C_B" "$nn" "$C_0"
    printf '  Страница N:  course page %s N\n' "$nn"
    printf '  Материалы:   course extra %s\n' "$nn"
    printf '  Задания:     course lab %s\n\n' "$nn"
}

open_page() {
    local nn="$1" i="$2" dir n f
    dir="$(day_dir "$nn")"
    n="$(pages_count "$dir")"
    [ "$n" -gt 0 ] || { show_file "$dir/material.md"; return; }
    [ "$i" -ge 1 ] || i=1
    [ "$i" -le "$n" ] || i="$n"
    f="$(page_file "$dir" "$i")"
    timing_start "day$nn"
    last_day_set "$nn"
    show_file "$f"
    page_read_mark "day$nn" "$(printf '%02d' "$i")"
    cur_page_set "day$nn" "$i"
    echo
    printf '%sСтраница %d из %d%s\n' "$C_DIM" "$i" "$n" "$C_0"
    if [ "$i" -lt "$n" ]; then
        printf 'Дальше: %scourse next%s   (оглавление: course toc %s)\n' "$C_B" "$C_0" "$nn"
    else
        printf 'Материал дня прочитан. Дальше: %scourse extra %s%s — что почитать глубже,\n' "$C_B" "$nn" "$C_0"
        printf 'или сразу к заданиям: %scourse lab %s%s\n' "$C_B" "$nn" "$C_0"
    fi
}

cmd_start() {
    local nn dir p
    nn="$(resolve_day "${1:-}")"; dir="$(day_dir "$nn")"
    run_setup "$dir" "$nn"
    p="$(cur_page_get "day$nn")"
    [ "$p" -ge 1 ] 2>/dev/null || p=0
    if [ "$p" -eq 0 ] && [ "$(pages_count "$dir")" -gt 0 ]; then cmd_toc "$nn"; fi
    [ "$p" -ge 1 ] || p=1
    open_page "$nn" "$p"
}

cmd_next() {
    local nn p n
    nn="$(resolve_day "${1:-}")"
    p="$(cur_page_get "day$nn")"; [ "$p" -ge 1 ] 2>/dev/null || p=0
    n="$(pages_count "$(day_dir "$nn")")"
    # За последней страницей next не показывает её заново: там материал кончился,
    # и человеку нужен следующий шаг, а не тот же текст.
    if [ "$n" -gt 0 ] && [ "$p" -ge "$n" ]; then
        printf '\n%sМатериал дня %s прочитан — страниц больше нет.%s\n\n' "$C_B" "$nn" "$C_0"
        printf 'Дальше по порядку:\n'
        printf '  Глубже:    %scourse extra %s%s\n' "$C_B" "$nn" "$C_0"
        printf '  Задания:   %scourse lab %s%s\n' "$C_B" "$nn" "$C_0"
        printf '  Проверка:  %scourse check %s%s\n' "$C_B" "$nn" "$C_0"
        printf '  Перечитать: %scourse page %s N%s   (оглавление: course toc %s)\n\n' \
               "$C_B" "$nn" "$C_0" "$nn"
        return 0
    fi
    open_page "$nn" $((p + 1))
}

cmd_prev() {
    local nn p
    nn="$(resolve_day "${1:-}")"
    p="$(cur_page_get "day$nn")"; [ "$p" -ge 2 ] 2>/dev/null || p=2
    open_page "$nn" $((p - 1))
}

cmd_page() {
    local nn i dir last n
    nn="$(resolve_day "${1:-}")"; i="${2:-1}"
    dir="$(day_dir "$nn")"
    last="$(last_day_get)"
    # Ссылка из раннего дня на памятку будущего («правила трассировки —
    # course page 15 2») — это подсмотреть, а не начать тот день. Раньше она
    # переключала «последний день» (course next уводил в день 15), запускала
    # таймер контрольной точки и разворачивала её стенд. Обычный переход на
    # следующий день (+1) и первый запуск курса сюда не попадают. Если свой
    # день уже сдан, стажёр идёт дальше, а не подсматривает: открываем как
    # обычно (иначе `course page 12 1` после закрытого дня 10 не разворачивал
    # стенд дня 12, и работа шла прямо в домашнем каталоге).
    if [ -n "$last" ] && [ $((10#$nn)) -gt $((10#$last + 1)) ] \
       && ! state_is_passed "day$last"; then
        n="$(pages_count "$dir")"
        [ "$i" -ge 1 ] 2>/dev/null || i=1
        [ "$i" -le "$n" ] || i="$n"
        show_file "$(page_file "$dir" "$i")"
        echo
        printf '%sЭто страница %d дня %s — вы сейчас на дне %s. Здесь только посмотреть:%s\n' \
               "$C_DIM" "$i" "$nn" "$last" "$C_0"
        printf '%sдень %s не начат, таймер не идёт. Вернуться к своему дню: course start %s%s\n' \
               "$C_DIM" "$nn" "$last" "$C_0"
        printf '%sНачать день %s по-настоящему: course start %s%s\n' \
               "$C_DIM" "$nn" "$nn" "$C_0"
        return 0
    fi
    run_setup "$dir" "$nn"
    open_page "$nn" "$i"
}

cmd_extra() {
    local nn dir
    nn="$(resolve_day "${1:-}")"; dir="$(day_dir "$nn")"
    [ -f "$dir/extra.md" ] || die "У дня $nn нет дополнительных материалов."
    show_file "$dir/extra.md"
}

cmd_lab() {
    local nn dir
    nn="$(resolve_day "${1:-}")"; dir="$(day_dir "$nn")"
    run_setup "$dir" "$nn"
    last_day_set "$nn"
    timing_start "day$nn"
    show_file "$dir/lab.md"
    printf '\nКогда сделаете: %scourse check %s%s\n' "$C_B" "$nn" "$C_0"
}

cmd_break() {
    local nn dir
    nn="$(resolve_day "${1:-}")"; dir="$(day_dir "$nn")"
    [ -f "$dir/break.sh" ] || die "В дне $nn поломок нет."
    CURRENT_DAY="day$nn" bash "$dir/break.sh"
}

# Проверку одного дня нельзя гонять чаще раза в CHECK_COOLDOWN секунд.
# Перебор «поправил наугад — проверил» учит угадывать, а не читать вывод
# своей программы. Все попытки пишутся в ~/.course/checks для отчёта.
CHECK_COOLDOWN="${COURSE_CHECK_COOLDOWN:-15}"
CHECKS_FILE="$COURSE_HOME/checks"

cmd_check() {
    local nn dir rc now_ last
    nn="$(resolve_day "${1:-}")"; dir="$(day_dir "$nn")"
    [ -f "$dir/check.sh" ] || die "Для дня $nn нет проверки."
    now_="$(now_ts)"
    last="$(awk -v d="day$nn" '$1==d {t=$2} END {print t+0}' "$CHECKS_FILE" 2>/dev/null)"
    if [ "$CHECK_COOLDOWN" -gt 0 ] && [ $((now_ - ${last:-0})) -lt "$CHECK_COOLDOWN" ]; then
        printf '\n%sПроверка дня %s запускалась %d с назад. Повторить можно через %d с.%s\n' \
               "$C_WARN" "$nn" $((now_ - last)) $((CHECK_COOLDOWN - (now_ - last))) "$C_0"
        printf 'Пока ждёте — запустите программу сами (go run . в её каталоге)\n'
        printf 'и сравните вывод с условием. Так ошибка находится быстрее, чем перебором.\n\n'
        return 1
    fi
    printf '\n%sДень %s — %s%s\n\n' "$C_B" "$nn" "$(day_title "$dir")" "$C_0"
    CURRENT_DAY="day$nn" CURRENT_DAY_DIR="$dir" bash "$dir/check.sh"
    rc=$?
    printf 'day%s %s %s\n' "$nn" "$now_" "$rc" >> "$CHECKS_FILE" 2>/dev/null
    # После проверки — фоновая выгрузка журнала куратору, если она настроена.
    # Молча и не мешая: сеть отвалилась — курс работает как работал.
    cmd_sync --quiet &
    ach_scan
    return $rc
}

# ---------- выгрузка журнала куратору ----------
# Открытая часть курса: в день 0 стажёр включает журнал команд и знает,
# что проверки на него смотрят. sync отправляет тот же журнал на сервер
# курса, чтобы прогресс группы был виден в одном месте. Журналы там
# складываются по IP клиента (его ставит сервер, не клиент), поэтому
# учётка выгрузки одна на всех — она лишь отсекает посторонних, а кто
# есть кто, различает IP. Значения зашиты ниже; файл ~/.course/sync.conf
# при желании их переопределяет (для другого стенда).
SYNC_URL_DEFAULT=""   # go-курс: свой сервер появится позже; пусто — выгрузки нет
SYNC_USER_DEFAULT="ingest"
SYNC_PASS_DEFAULT="9d823812eb3c2b2bca398db1"
cmd_sync() {
    local quiet=""; [ "${1:-}" = "--quiet" ] && quiet=1
    local url="$SYNC_URL_DEFAULT" user="$SYNC_USER_DEFAULT" pass="$SYNC_PASS_DEFAULT"
    local conf="$COURSE_HOME/sync.conf"
    if [ -f "$conf" ]; then
        # shellcheck disable=SC1090
        . "$conf"
        url="${SYNC_URL:-$url}"; user="${SYNC_USER:-$user}"; pass="${SYNC_PASS:-$pass}"
    fi
    [ -n "$url" ] && [ -n "$user" ] || return 0
    [ -s "$CMDLOG" ] || return 0

    local code
    code="$(curl -s -o /dev/null -w '%{http_code}' \
        --max-time 20 --retry 1 \
        -u "$user:$pass" \
        -H "X-Course-Day: $(current_day)" \
        -H "X-Course-Machine: $(machine_id)" \
        --data-binary @"$CMDLOG" \
        "$url/$user" 2>/dev/null)" || code=000

    if [ "$code" = "200" ] || [ "$code" = "201" ] || [ "$code" = "204" ]; then
        printf '%s' "$(now_ts)" > "$COURSE_HOME/synced"
        [ -z "$quiet" ] && printf 'Журнал отправлен.\n'
    else
        [ -z "$quiet" ] && printf 'Не удалось отправить журнал (код %s). Курсу это не мешает.\n' "$code"
    fi

    # Ачивки — на сайт (страница /achievements). Отдельно от журнала: сайт
    # пишет их в свой каталог, поэтому права приёмника тут ни при чём.
    local ach="$COURSE_HOME/achievements" base="${url%/ingest}"
    if [ -s "$ach" ] && [ "$base" != "$url" ]; then
        curl -s -o /dev/null --max-time 15 --retry 1 \
            -u "$user:$pass" \
            --data-binary @"$ach" \
            "$base/ach/$(machine_id)" 2>/dev/null || true
    fi
    return 0
}

# ---------- контрольные вопросы ----------
# Формат quiz.txt:  вопрос|вариант1|вариант2|вариант3|вариант4|номер-верного

cmd_quiz() {
    local nn dir qfile total ask i line q correct got right=0 asked=0
    nn="$(resolve_day "${1:-}")"; dir="$(day_dir "$nn")"
    qfile="$dir/quiz.txt"
    obf_exists "$qfile" || die "У дня $nn нет контрольных вопросов."

    # Квиз — последний шаг дня, а не шпаргалка: пока пункты дня не сданы,
    # банк вопросов не открывается (отметку ставит chk_summary).
    if ! flag_has "day$nn" "work-ok"; then
        die "Сначала сдайте пункты дня: course check $nn
Контрольные вопросы — последний шаг, они открываются, когда работа зачтена."
    fi

    # Отвечать можно только с клавиатуры: ответы читаются из /dev/tty,
    # чтобы их нельзя было подать на вход из файла или конвейера.
    if ! : 2>/dev/null </dev/tty; then
        die "Контрольные вопросы отвечаются только с клавиатуры, в обычном терминале.
Подать ответы из файла или по конвейеру нельзя — это часть проверки."
    fi

    # Переменная не local: её чистит ловушка EXIT, а к тому моменту
    # cmd_quiz уже завершилась и local был бы недоступен (set -u).
    QUIZ_TMP="$(mktemp)"
    trap 'rm -f "${QUIZ_TMP:-}"' EXIT
    obf_read "$qfile" > "$QUIZ_TMP"
    qfile="$QUIZ_TMP"

    total="$(grep -vc '^#\|^$' "$qfile")"
    ask=3; [ "$total" -lt 3 ] && ask="$total"

    printf '\n%sКонтрольные вопросы — день %s%s\n' "$C_B" "$nn" "$C_0"
    printf '%s%d случайных вопроса из %d. Отвечать надо с первого раза:\n' "$C_DIM" "$ask" "$total"
    printf 'подсказки здесь не работают, пересдача — заново со случайными вопросами.%s\n\n' "$C_0"

    # случайная выборка строк
    local picked
    picked="$(grep -v '^#\|^$' "$qfile" | shuf -n "$ask" 2>/dev/null)"
    if [ -z "$picked" ]; then
        # Запасной путь, если shuf недоступен. Генератор сеем явно:
        # srand() без аргумента берёт время в секундах, и два запуска
        # в одну секунду дали бы одинаковую выборку.
        picked="$(grep -v '^#\|^$' "$qfile" \
            | awk -v s="$RANDOM$$" 'BEGIN{srand(s)}{print rand()"\t"$0}' \
            | sort -n | head -n "$ask" | cut -f2-)"
    fi

    while IFS= read -r line; do
        [ -n "$line" ] || continue
        asked=$((asked + 1))
        q="$(printf '%s' "$line" | cut -d'|' -f1)"
        local src_correct; src_correct="$(printf '%s' "$line" | cut -d'|' -f6)"

        # Варианты перемешиваются при каждой выдаче. В банке верный ответ
        # записан под номером 1 — так удобнее его составлять, — но стажёр
        # видит их в случайном порядке, иначе квиз проходился бы вслепую.
        local order shown k idx opt
        order="$(printf '1\n2\n3\n4\n' | shuf 2>/dev/null)"
        [ -n "$order" ] || order="$(printf '1\n2\n3\n4\n' \
            | awk -v s="$RANDOM$$$asked" 'BEGIN{srand(s)}{print rand()"\t"$0}' \
            | sort -n | cut -f2)"

        printf '%s%d. %s%s\n' "$C_B" "$asked" "$q" "$C_0"
        k=0; correct=0
        while IFS= read -r idx; do
            [ -n "$idx" ] || continue
            k=$((k + 1))
            opt="$(printf '%s' "$line" | cut -d'|' -f$((idx + 1)))"
            [ "$idx" = "$src_correct" ] && correct="$k"
            printf '   %d) %s\n' "$k" "$opt"
        done <<ORD
$order
ORD

        printf 'Ваш ответ (1-4): '
        read -r got </dev/tty
        if [ "$got" = "$correct" ]; then
            printf '   %sверно%s\n\n' "$C_OK" "$C_0"; right=$((right + 1))
        else
            printf '   %sневерно, правильный ответ — %s%s\n\n' "$C_NO" "$correct" "$C_0"
        fi
    done <<EOF
$picked
EOF

    printf '%s\n' "$(now)" >/dev/null
    printf 'day%s %s %s %s\n' "$nn" "$(now)" "$right" "$asked" >> "$QUIZ_FILE"

    if [ "$right" -eq "$asked" ]; then
        if state_is_passed "day$nn"; then
            printf '%sВсе %d верно. День %s уже закрыт.%s\n\n' "$C_OK" "$asked" "$nn" "$C_0"
        else
            printf '%sВсе %d верно. Запустите course check %s — если остальное сдано, день закроется.%s\n\n' \
                   "$C_OK" "$asked" "$nn" "$C_0"
        fi
    else
        printf '%sВерно %d из %d. Нужны все.%s\n' "$C_NO" "$right" "$asked" "$C_0"
        printf '%sЭто не наказание: вопросы проверяют то, что вы будете применять\n' "$C_DIM"
        printf 'каждый день. Перечитайте материал (course toc %s) и попробуйте снова.%s\n\n' "$nn" "$C_0"
        flag_set "day$nn" "quiz-retry"
    fi
}

# ---------- прочее ----------

cmd_answer() {
    local key="${1:-}"
    [ -n "$key" ] || die "Использование: course answer <ключ> <значение>"
    shift
    # Ответ может состоять из нескольких слов — например «df -i».
    # Берём всё, что после ключа, а не только первое слово.
    local val="$*"
    [ -n "$val" ] || die "Использование: course answer <ключ> <значение>"
    answer_set "$key" "$val"
    printf 'Принято: %s = %s\n' "$key" "$val"
}

# printf в bash считает ширину поля в байтах, а кириллическая буква
# занимает два — из-за этого %-46s недобирает отступ и колонки разъезжаются.
# Дополняем пробелами сами, считая символы.
# Длина в символах, а не в байтах, и без оглядки на локаль: ${#s} считает
# символы только в UTF-8-локали, а она бывает не задана. Продолжающие байты
# UTF-8 лежат в диапазоне \200-\277 — выкидываем их и считаем остальное.
str_len() { printf '%s' "$1" | LC_ALL=C tr -d '\200-\277' | LC_ALL=C wc -c | tr -d ' '; }

pad_to() {
    local s="$1" w="$2" len
    len="$(str_len "$s")"
    printf '%s' "$s"
    while [ "$len" -lt "$w" ]; do printf ' '; len=$((len + 1)); done
}

cmd_status() {
    local d nn total=0 done_=0 title n pr w=0
    printf '\n%sПрогресс%s\n\n' "$C_B" "$C_0"
    for d in $(all_days); do
        title="$(day_title "$d")"
        [ "$(str_len "$title")" -gt "$w" ] && w="$(str_len "$title")"
    done
    for d in $(all_days); do
        nn="${d##*/day}"; total=$((total + 1))
        title="$(day_title "$d")"
        n="$(pages_count "$d")"; pr="$(pages_read_count "day$nn")"
        if state_is_passed "day$nn"; then
            done_=$((done_ + 1))
            printf '  %s✔%s  %s  %s\n' "$C_OK" "$C_0" "$nn" "$title"
        else
            printf '  %s·%s  %s  ' "$C_DIM" "$C_0" "$nn"
            pad_to "$title" "$w"
            printf '  %sстр. %s/%s%s\n' "$C_DIM" "$pr" "$n" "$C_0"
        fi
    done
    printf '\nСдано %d из %d. Подсказок всего: %s, из них готовых решений: %s\n' \
           "$done_" "$total" \
           "$(awk '{n+=$3} END {print n+0}' "$HINTS_FILE")" \
           "$(awk '$3>=3 {n++} END {print n+0}' "$HINTS_FILE")"
    printf 'Команд выполнено: %s\n' "$(cmdlog_size)"
    if [ "$done_" -eq "$total" ]; then
        printf '%sВсе дни закрыты.%s\n\n' "$C_OK" "$C_0"
    else
        printf 'Текущий день: %s%s%s  →  course start\n\n' "$C_B" "$(current_day)" "$C_0"
    fi
}

# Служебное закрытие дня. Нужно на случай, когда стенд разрушен не по вине
# стажёра: переустановили ВМ, слетел снапшот, пропало состояние. Без этого
# человеку пришлось бы проходить весь день заново.
#
# Вызывается словом, которого в этом файле нет: сверяется только его хеш.
# В отчёте остаётся пометка manual — чтобы куратор видел, что день закрыт
# не обычным путём.
STATE_FIX_H="9407a684b02e821663062087d7e95ed8080fc93530192ea8e5929a644fd14bdf"

cmd_state_fix() {
    local nn what dir
    nn="$(resolve_day "${1:-}")"
    what="${2:-day}"
    dir="$(day_dir "$nn")" || exit 1

    case "$what" in
        quiz)
            printf 'day%s %s 3 3\n' "$nn" "$(now)" >> "$QUIZ_FILE"
            flag_set "day$nn" "manual-quiz"
            printf 'День %s: контрольные вопросы отмечены сданными.\n' "$nn"
            printf 'Проверку дня выполните обычным путём: course check %s\n' "$nn"
            ;;
        *)
            printf 'day%s %s 3 3\n' "$nn" "$(now)" >> "$QUIZ_FILE"
            state_set_passed "day$nn"
            flag_set "day$nn" "manual"
            printf 'День %s закрыт служебным порядком.\n' "$nn"
            printf 'В отчёте останется пометка manual.\n'
            ;;
    esac
}

cmd_report() {
    local d nn when status fl
    printf 'стажёр;%s\n' "$(whoami)"
    printf 'машина;%s\n' "$(hostname)"
    printf 'сформировано;%s\n' "$(now)"
    printf 'команд выполнено;%s\n\n' "$(cmdlog_size)"
    printf 'день;статус;сдан;минут;страниц;подсказок;решений;проверок;пометки\n'
    for d in $(all_days); do
        nn="${d##*/day}"
        when="$(awk -v x="day$nn" '$1==x {print $3}' "$PROGRESS_FILE" | tail -n1)"
        if [ -n "$when" ]; then status="сдан"; else status="не сдан"; when=""; fi
        fl="$(awk -v x="day$nn" '$1==x {printf "%s ", $2}' "$FLAGS_FILE")"
        printf '%s;%s;%s;%s;%s;%s;%s;%s;%s\n' \
            "$nn" "$status" "$when" "$(timing_minutes "day$nn")" \
            "$(pages_read_count "day$nn")" "$(hints_taken "day$nn")" \
            "$(hints_deep "day$nn")" \
            "$(awk -v d="day$nn" '$1==d {n++} END {print n+0}' "$COURSE_HOME/checks" 2>/dev/null)" \
            "${fl% }"
    done
}

cmd_doctor() {
    printf '\n%sПроверка окружения%s\n\n' "$C_B" "$C_0"
    local ok=1
    if grep -q 'course_log_cmd' "$HOME/.bashrc" 2>/dev/null; then
        printf '  %s✔%s  журнал команд включён\n' "$C_OK" "$C_0"
    else
        printf '  %s✘%s  журнал команд НЕ включён — проверки «сделано руками» работать не будут\n' "$C_NO" "$C_0"
        printf '       почините: %scourse setup-shell%s, потом перезайдите\n' "$C_B" "$C_0"
        ok=0
    fi
    printf '  %s·%s  команд в журнале: %s\n' "$C_DIM" "$C_0" "$(cmdlog_size)"

    # В локали C оболочка режет многобайтные символы, и команды с кириллицей
    # попадают в журнал обрезанными. На Ubuntu локаль UTF-8, но по ssh
    # с чужой машины её может и не быть.
    local loc; loc="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
    case "$loc" in
        *UTF-8*|*utf8*|*UTF8*)
            printf '  %s✔%s  локаль: %s\n' "$C_OK" "$C_0" "$loc" ;;
        "")
            printf '  %s!%s  локаль не задана — команды с кириллицей могут писаться в журнал обрезанными\n' "$C_WARN" "$C_0"
            printf '       лечится: export LANG=C.UTF-8\n' ;;
        *)
            printf '  %s!%s  локаль «%s» не UTF-8 — команды с кириллицей могут обрезаться\n' "$C_WARN" "$C_0" "$loc"
            printf '       лечится: export LANG=C.UTF-8\n' ;;
    esac
    printf '  %s·%s  каталог курса: %s\n' "$C_DIM" "$C_0" "$COURSE_ROOT"
    printf '  %s·%s  состояние: %s\n' "$C_DIM" "$C_0" "$COURSE_HOME"
    local want_go="go1.27.1" got_go
    got_go="$(PATH="/usr/local/go/bin:$PATH" go version 2>/dev/null | awk '{print $3}')"
    if [ "$got_go" = "$want_go" ]; then
        printf '  %s✔%s  Go: %s\n' "$C_OK" "$C_0" "$got_go"
    elif [ -n "$got_go" ]; then
        printf '  %s✘%s  Go: %s, а курс рассчитан на %s — повторите sudo bash prepare.sh\n' "$C_NO" "$C_0" "$got_go" "$want_go"
        ok=0
    else
        printf '  %s✘%s  Go не найден — выполните день 0: sudo bash prepare.sh\n' "$C_NO" "$C_0"
        ok=0
    fi
    local tool; for tool in gopls dlv golangci-lint; do
        if command -v "$tool" >/dev/null 2>&1; then
            printf '  %s✔%s  %s\n' "$C_OK" "$C_0" "$tool"
        else
            printf '  %s!%s  нет %s — редактор будет подсказывать хуже; повторите prepare.sh\n' "$C_WARN" "$C_0" "$tool"
        fi
    done
    local m; for m in awk sed grep sort uniq wc less nano diff; do
        command -v "$m" >/dev/null 2>&1 || { printf '  %s✘%s  нет утилиты: %s\n' "$C_NO" "$C_0" "$m"; ok=0; }
    done
    [ "$ok" -eq 1 ] && printf '\n%sВсё на месте.%s\n\n' "$C_OK" "$C_0" || printf '\n'
}

cmd_setup_shell() {
    local rc="$HOME/.bashrc" added=0

    # Хук журнала команд. Ставится один раз; повторный запуск не дублирует.
    if ! grep -q 'course_log_cmd' "$rc" 2>/dev/null; then
        cat >> "$rc" <<'EOF'

# --- тренажёр стажировки: журнал выполненных команд ---
# Пишет то, что вы вводите, в ~/.course/cmdlog. Нужен для проверок,
# которые смотрят не только на результат, но и на то, как он получен.
course_log_cmd() {
    local last
    last="$(history 1)"
    # Номер команды отрезаем средствами самой оболочки, а не sed: во внешних
    # утилитах при локали C многобайтные символы режутся посередине, и запись
    # в журнале обрывается. Команды с кириллицей встречаются — например
    # course answer day02.hiddenword скрытые
    last="${last#"${last%%[![:space:]]*}"}"
    last="${last#* }"
    last="${last#"${last%%[![:space:]]*}"}"
    [ -n "$last" ] && printf '%s\t%s\n' "$(date +%s)" "$last" >> "$HOME/.course/cmdlog" 2>/dev/null
}
case "$PROMPT_COMMAND" in
    *course_log_cmd*) ;;
    "") PROMPT_COMMAND="course_log_cmd" ;;
    *)  PROMPT_COMMAND="course_log_cmd; $PROMPT_COMMAND" ;;
esac
EOF
        added=1
    fi

    # Регулярная выгрузка журнала. Самолечится: старый блок (в т.ч. версия
    # с проверкой sync.conf) вырезается и ставится свежий — так апгрейд
    # прилетает по повторному course setup-shell. Раз в 20 минут, молча.
    if grep -q 'course_sync_tick' "$rc" 2>/dev/null; then
        # вырезать прежний блок выгрузки целиком (от маркера до его esac)
        awk '
            /# --- тренажёр стажировки: регулярная выгрузка/ { skip=1 }
            skip && /^esac$/ { skip=0; next }
            !skip { print }
        ' "$rc" > "$rc.tmp" && mv -f "$rc.tmp" "$rc"
    fi
    cat >> "$rc" <<'EOF'

# --- тренажёр стажировки: регулярная выгрузка журнала ---
# Раз в 20 минут работы в терминале отправляет журнал (course sync).
# Учётка зашита в курс, поэтому sync.conf не нужен. Молча, в фоне;
# сервер недоступен — курсу не мешает.
course_sync_tick() {
    command -v course >/dev/null 2>&1 || return 0
    local now last f="$HOME/.course/synced"
    now="$(date +%s)"
    last="$(cat "$f" 2>/dev/null)"; case "$last" in ''|*[!0-9]*) last=0;; esac
    if [ $(( now - last )) -ge 1200 ]; then
        printf '%s' "$now" > "$f" 2>/dev/null
        ( course sync --quiet >/dev/null 2>&1 & )
    fi
}
case "$PROMPT_COMMAND" in
    *course_sync_tick*) ;;
    "") PROMPT_COMMAND="course_sync_tick" ;;
    *)  PROMPT_COMMAND="course_sync_tick; $PROMPT_COMMAND" ;;
esac
EOF
    added=1

    if [ "$added" -eq 1 ]; then
        printf 'Настройка оболочки обновлена. Выполните: %ssource ~/.bashrc%s\n' "$C_B" "$C_0"
    else
        printf 'Оболочка уже настроена: журнал и выгрузка на месте.\n'
    fi
}

cmd_web() {
    local port="${1:-8080}"
    local site="$COURSE_ROOT/tools/site.py"
    [ -f "$site" ] || die "Просмотрщик не установлен: нет $site"
    command -v python3 >/dev/null 2>&1 || die "Нужен python3: sudo apt install -y python3"

    printf '\n%sМатериал в браузере%s\n\n' "$C_B" "$C_0"
    printf '  Внутри машины:   http://localhost:%s\n' "$port"
    printf '  С вашего ноутбука — нужен проброс порта %s (см. инструкцию дня 0),\n' "$port"
    printf '  тогда откроется по адресу http://localhost:%s\n\n' "$port"
    printf '%sПодсказки и проверки здесь не показываются — они в терминале:\n' "$C_DIM"
    printf 'course hint, course check, course quiz.%s\n\n' "$C_0"
    printf 'Остановить: Ctrl+C\n\n'

    python3 "$site" "$port"
}

usage() {
    cat <<'EOF'

  course — тренажёр стажировки

  Материал
    course toc [NN]       оглавление дня
    course start [NN]     читать с текущей страницы
    course next / prev    следующая / предыдущая страница
    course page NN N      открыть страницу N
    course extra [NN]     дополнительные материалы и что почитать глубже

  Работа
    course lab [NN]       задания
    course break [NN]     подложить поломку на стенд
    course check [NN]     проверить себя
    course quiz [NN]      контрольные вопросы по материалу
    course ach            ваши ачивки
    course hint NN ID     подсказка, три уровня по нарастающей
    course ask NN [ТИКЕТ [N]]  вопросы тимлиду по тикету
    course answer K V     отправить ответ на вопрос задания

  Служебное
    course web [порт]     открыть материал в браузере (по умолчанию 8080)
    course status         что сдано
    course report         выгрузка прогресса (CSV)
    course doctor         проверить окружение
    course setup-shell    включить журнал команд

  Застряли? 20 минут сами, потом course hint — три уровня по нарастающей.
  Если и это не помогло, не сидите на месте: возьмите следующий день,
  а к этому вернитесь позже.

EOF
}

cmd_hint() {
    local nn id lvl file taken dir
    nn="$(resolve_day "${1:-}")"; id="${2:-}"
    [ -n "$id" ] || die "Укажите пункт: course hint $nn <id>"
    dir="$(day_dir "$nn")"
    taken="$(awk -v d="day$nn" -v q="$id" '$1==d && $2==q {n=$3} END {print n+0}' "$HINTS_FILE")"
    lvl=$((taken + 1))
    [ "$lvl" -le 3 ] || lvl=3
    file="$dir/hints/$id.$lvl"
    obf_exists "$file" || die "Подсказки «$id» в дне $nn нет. Проверьте id в выводе course check."

    if [ "$lvl" -eq 3 ] && [ "$taken" -lt 3 ]; then
        printf '\n%sТретий уровень — самый подробный.%s\n' "$C_WARN" "$C_0"
        printf 'На обычных днях это готовое решение, на контрольной точке — разбор способа.\n'
        printf 'Он попадёт в отчёт, и тема с большой вероятностью не осядет.\n'
        printf 'Открыть? [y/N] '
        local yn=n; read -r yn </dev/tty 2>/dev/null || yn=n
        case "$yn" in y|Y|д|Д) ;; *) printf 'Отменено. Попробуйте ещё раз сами.\n\n'; return 0 ;; esac
    fi

    printf '\n%sПодсказка %d из 3 — %s%s\n\n' "$C_B" "$lvl" "$id" "$C_0"
    obf_read "$file"; echo

    if [ "$lvl" -gt "$taken" ]; then
        grep -v "^day$nn $id " "$HINTS_FILE" > "$HINTS_FILE.tmp" 2>/dev/null || true
        mv -f "$HINTS_FILE.tmp" "$HINTS_FILE" 2>/dev/null || true
        printf 'day%s %s %d\n' "$nn" "$id" "$lvl" >> "$HINTS_FILE"
    fi
    [ "$lvl" -lt 3 ] && printf '%sЕсли не помогло: course hint %s %s%s\n' "$C_DIM" "$nn" "$id" "$C_0"
    return 0
}


# ---------- тимлид ----------
# К тикету приложены карточки «вопрос → ответ» (dayNN/ask/<тикет>.md).
# Видны только вопросы. Карточка [code] — ответ лежит в репозитории:
# открывается через COURSE_ASK_DELAY секунд после первого взгляда на
# список (правило «сначала полчаса сам») и считается подсказкой.
# Карточка [business] — решение, которого в коде нет: отвечает сразу
# и бесплатно. Спрашивать такое — правильно.
ASK_DELAY="${COURSE_ASK_DELAY:-1200}"
ASK_SEEN="$COURSE_HOME/ask.seen"

ask_file() { printf '%s/ask/%s.md' "$1" "$2"; }
ask_seen_ts() { awk -v d="$1" -v t="$2" '$1==d && $2==t {print $3}' "$ASK_SEEN" 2>/dev/null | head -1; }
ask_mark_seen() {
    [ -n "$(ask_seen_ts "$1" "$2")" ] || printf '%s %s %s\n' "$1" "$2" "$(now_ts)" >> "$ASK_SEEN"
}

cmd_ask() {
    local nn dir ticket n file tmp
    nn="$(resolve_day "${1:-}")"; ticket="${2:-}"; n="${3:-}"
    dir="$(day_dir "$nn")"
    if [ -z "$ticket" ]; then
        printf '\n%sТикеты дня %s с вопросами тимлиду:%s\n' "$C_B" "$nn" "$C_0"
        for file in "$dir"/ask/*.md "$dir"/ask/*.md.enc; do
            [ -e "$file" ] || continue
            file="$(basename "$file")"; file="${file%.enc}"; printf '  %s\n' "${file%.md}"
        done
        printf '\n%scourse ask %s ТИКЕТ%s — список вопросов\n' "$C_DIM" "$nn" "$C_0"
        return 0
    fi
    file="$(ask_file "$dir" "$ticket")"
    obf_exists "$file" || die "У тикета $ticket в дне $nn нет вопросов тимлиду. Список: course ask $nn"
    tmp="$(mktemp)"; obf_read "$file" > "$tmp"
    ask_mark_seen "day$nn" "$ticket"

    if [ -z "$n" ]; then
        printf '\n%s%s%s\n' "$C_B" "$(head -1 "$tmp" | sed 's/^# *//')" "$C_0"
        printf '%s[code] — ответ есть в репозитории, откроется через %d мин и запишется как подсказка.\n' "$C_DIM" "$((ASK_DELAY / 60))"
        printf '[business] — решение, которого в коде нет: отвечает сразу, бесплатно.%s\n\n' "$C_0"
        awk '/^## /{n++; sub(/^## /,""); printf "  %d. %s\n", n, $0}' "$tmp"
        printf '\n%scourse ask %s %s НОМЕР%s — задать вопрос\n' "$C_DIM" "$nn" "$ticket" "$C_0"
        rm -f "$tmp"; return 0
    fi

    local q kind ans total
    total="$(grep -c '^## ' "$tmp")"
    case "$n" in ''|*[!0-9]*) rm -f "$tmp"; die "Номер вопроса — число от 1 до $total" ;; esac
    [ "$n" -ge 1 ] && [ "$n" -le "$total" ] || { rm -f "$tmp"; die "Вопросов всего $total"; }
    q="$(awk -v k="$n" '/^## /{c++} c==k && /^## /{sub(/^## /,""); print; exit}' "$tmp")"
    ans="$(awk -v k="$n" '/^## /{c++; next} c==k' "$tmp" | sed '/^[[:space:]]*$/d')"
    rm -f "$tmp"
    case "$q" in *"[business]"*) kind=business ;; *) kind=code ;; esac

    if [ "$kind" = code ]; then
        local since waited
        since="$(ask_seen_ts "day$nn" "$ticket")"; waited=$(( $(now_ts) - ${since:-0} ))
        if [ "$waited" -lt "$ASK_DELAY" ]; then
            printf '\n%s%s%s\n' "$C_B" "$q" "$C_0"
            printf '%sТимлид на созвоне, освободится минут через %d.%s\n' "$C_WARN" "$(( (ASK_DELAY - waited + 59) / 60 ))" "$C_0"
            printf 'Ответ на этот вопрос есть в репозитории — попробуйте найти сами:\n'
            printf 'grep -rn, git log, git blame. Если не выйдет — спросите ещё раз позже.\n\n'
            return 0
        fi
        grep -q "^day$nn ask:$ticket:$n " "$HINTS_FILE" 2>/dev/null \
            || printf 'day%s ask:%s:%s 1\n' "$nn" "$ticket" "$n" >> "$HINTS_FILE"
    fi
    printf '\n%s%s%s\n\n' "$C_B" "$q" "$C_0"
    printf '%s\n\n' "$ans"
    [ "$kind" = code ] && printf '%s(записано как подсказка: ответ был в репозитории)%s\n' "$C_DIM" "$C_0"
    return 0
}

# ---------- machine-id ----------
# Случайный устойчивый идентификатор машины стажёра. Пишется один раз,
# уходит заголовком при синке — сервер группирует журнал/ачивки по нему,
# а не по IP (WARP/NAT больше не плодят «пользователей»).
machine_id() {
    local f="$COURSE_HOME/machine-id" id
    id="$(cat "$f" 2>/dev/null)"
    if [ -z "$id" ]; then
        id="$( { cat /proc/sys/kernel/random/uuid 2>/dev/null \
                 || od -An -N16 -tx1 /dev/urandom | tr -d ' \n'; } | tr -d '\n' )"
        printf '%s' "$id" > "$f" 2>/dev/null || true
    fi
    printf '%s' "$id"
}

# ---------- ачивки ----------
# Каталог: id|эмодзи Имя|за что. Считаются автоматически из состояния
# (~/.course/progress, flags, hints) и журнала команд (~/.course/cmdlog).
ach_catalog() {
    cat <<'EOF'
hello|👋 Hello, World|закрыть день 1
kt1|🧮 Считаю в копейках|взять контрольную точку 1 (день 5)
kt2|🔁 Цикл замкнулся|взять контрольную точку 2 (день 10)
grad|🐹 Суслик-выпускник|пройти весь курс (день 65)
errors100|📖 Словарь Даля|100 разборов в ~/errors.md
solo|🥷 Соло без подсказок|сдать день, не открыв ни одной подсказки
gigachad|🗿 Гигачад|сдать контрольную без пересдач
cheater|🃏 Читер со стажем|открыть подсказку 3-го уровня
brute|🎰 Метод тыка|прогнать один check больше 10 раз
bug|🐞 Жук пойман|запустить отладчик dlv
paste|📋 Копипастер 3000|вставить строку вместе с приглашением gopher@lab:~$
night|🦉 Ночной дозор|команды после полуночи
EOF
}

# Печатает id всех ачивок, условие которых уже выполнено.
ach_satisfied() {
    local cl="$CMDLOG" fl="$FLAGS_FILE" err="$HOME/errors.md" nn
    state_is_passed day01 && echo hello
    state_is_passed day05 && echo kt1
    state_is_passed day10 && echo kt2
    state_is_passed day65 && echo grad
    [ -f "$err" ] && [ "$(grep -cE '.{20,}.*[-—–:]' "$err" 2>/dev/null || echo 0)" -ge 100 ] && echo errors100
    grep -q ' deep-hints$' "$fl" 2>/dev/null && echo cheater
    for nn in $(awk '$2=="passed"{print $1}' "$PROGRESS_FILE" 2>/dev/null); do
        [ "$(hints_taken "$nn")" = "0" ] && { echo solo; break; }
    done
    for nn in day05 day10 day15 day20 day25 day30 day35 day40 day45 day50 day55 day60 day65; do
        state_is_passed "$nn" && ! flag_has "$nn" exam-failed && { echo gigachad; break; }
    done
    [ -s "$cl" ] || return 0
    grep -oE 'course check [0-9]+' "$cl" | sort | uniq -c \
        | awk '$1>10{f=1} END{exit !f}' && echo brute
    grep -qE '(^|\t)[[:space:]]*dlv[[:space:]]+(debug|test|exec)' "$cl" && echo bug
    grep -qE '@lab:[^$]*\$ ' "$cl" && echo paste
    local offz sign hh mm off_sec
    offz="$(date +%z)"; sign="${offz%????}"; hh="${offz:1:2}"; mm="${offz:3:2}"
    off_sec=$(( 10#$hh*3600 + 10#$mm*60 )); [ "$sign" = "-" ] && off_sec=$(( -off_sec ))
    awk -F'\t' -v off="$off_sec" '$1 ~ /^[0-9]+$/ {h=int((($1+off)%86400)/3600); if(h>=0 && h<5) f=1} END{exit !f}' "$cl" \
        && echo night
    return 0
}

# Считает ачивки; новые открытые печатает и запоминает (чтобы не орать дважды).
ach_scan() {
    local store="$COURSE_HOME/achievements" id name
    touch "$store" 2>/dev/null || return 0
    ach_satisfied 2>/dev/null | sort -u | while read -r id; do
        [ -n "$id" ] || continue
        grep -q "^$id$" "$store" 2>/dev/null && continue
        printf '%s\n' "$id" >> "$store"
        name="$(ach_catalog | awk -F'|' -v i="$id" '$1==i{print $2}')"
        printf '%s🏆 Ачивка открыта: «%s»%s\n' "${C_B:-}" "$name" "${C_0:-}"
    done
    return 0
}

cmd_ach() {
    local store="$COURSE_HOME/achievements" id name desc n=0 tot=0
    touch "$store" 2>/dev/null
    printf '\n%sАчивки%s\n\n' "${C_B:-}" "${C_0:-}"
    while IFS='|' read -r id name desc; do
        [ -n "$id" ] || continue
        tot=$((tot+1))
        if grep -q "^$id$" "$store" 2>/dev/null; then
            n=$((n+1)); printf '  %s%s%s — %s\n' "${C_B:-}" "$name" "${C_0:-}" "$desc"
        else
            printf '  %s· закрыто — %s%s\n' "${C_DIM:-}" "$desc" "${C_0:-}"
        fi
    done <<EOF
$(ach_catalog)
EOF
    printf '\n  открыто %d из %d\n\n' "$n" "$tot"
}

case "${1:-}" in
    toc)    shift; cmd_toc    "${1:-}" ;;
    start)  shift; cmd_start  "${1:-}" ;;
    next)   shift; cmd_next   "${1:-}" ;;
    prev)   shift; cmd_prev   "${1:-}" ;;
    page)   shift; cmd_page   "${1:-}" "${2:-1}" ;;
    extra)  shift; cmd_extra  "${1:-}" ;;
    lab)    shift; cmd_lab    "${1:-}" ;;
    break)  shift; cmd_break  "${1:-}" ;;
    check)  shift; cmd_check  "${1:-}" ;;
    quiz)   shift; cmd_quiz   "${1:-}" ;;
    ach|achievements|ачивки) cmd_ach ;;
    hint)   shift; cmd_hint   "${1:-}" "${2:-}" ;;
    ask)    shift; cmd_ask    "${1:-}" "${2:-}" "${3:-}" ;;
    answer) shift; cmd_answer "$@" ;;
    web)    shift; cmd_web    "${1:-}" ;;
    status) cmd_status ;;
    report) cmd_report ;;
    doctor) cmd_doctor ;;
    sync)   shift; cmd_sync "${1:-}" ;;
    setup-shell) cmd_setup_shell ;;
    ""|-h|--help|help) usage ;;
    *)  if [ "$(hash_of "$1")" = "$STATE_FIX_H" ]; then
            shift; cmd_state_fix "$@"
        else
            die "Неизвестная команда: $1. Список: course help"
        fi ;;
esac
