/**
 * Баннеры «Пройти опрос» и «Заполнить данные родителя» в кабинете GetCourse
 *
 * Скрипт лежит в теме аккаунта 67661 и подключается в <head> на всех
 * страницах кабинета, поэтому сам отсеивает чужие. Работает на тренингах,
 * уроках, уроке в приложении (lesson/webview) и на странице «Тренинги».
 *
 * Признак «заполнено» клиентскому JS напрямую недоступен. Канал один —
 * HTML-блок .js-survey-flag, который сервер отрисовывает по-своему для
 * каждого ученика:
 *
 *   NPS — блок в описании тренинга с «Заменять переменные», значение поля
 *   подставляется сервером:
 *     <div class="js-survey-flag" data-field="Опрос_физика_2026-08"
 *          data-value="{Опрос_физика_2026-08}"></div>
 *
 *   анкета родителя — блок на странице «Тренинги», скрытый от сегмента
 *   заполнивших: есть блок — не заполнено, нет блока — заполнено.
 *     <div class="js-survey-flag" data-marker="parent-data"></div>
 *
 * Баннеров на странице может быть несколько. Модалка одна за раз, остальные
 * висят полосами стопкой внизу слева. Подробно — README и спека
 * docs/superpowers/specs/2026-11-11-two-banners-design.md.
 */
(function () {
    'use strict';

    var CONFIG = {
        enabled: true,           // общий рубильник: false — скрипт молчит везде

        // Как стоят свёрнутые полосы на десктопе, когда их несколько:
        //   'row'    — в строку, слева направо в порядке конфига
        //   'column' — столбцом снизу вверх, все по ширине самой широкой
        // На телефоне всегда столбец: в строку они не помещаются.
        strips: 'row',

        debug: false             // печатать в консоль каждый отказ, включая штатные
    };

    // Запись баннера:
    //   key        имя состояния в localStorage; новая волна опроса — новый key
    //   enabled    false — баннер выключен
    //   audience   кому показывать:
    //                'users'  — только ученикам, боевой режим
    //                'admins' — только сотрудникам: обкатать баннер на себе,
    //                           ученики при этом ничего не видят
    //                'all'    — всем
    //              Имена — ключи сегментов GetCourse, как в других скриптах.
    //              Старые 'students' / 'staff' не действуют.
    //              Незнакомое значение или пропуск — поломка, баннер не видит никто.
    //   trainings  'all' — все тренинги, уроки и страница «Тренинги»;
    //              [id, …] — эти тренинги со всеми потомками
    //   except     id тренингов, где не показывать, тоже с потомками
    //   field      доп. поле-дата: заполнено — опрос пройден (блок в тренинге)
    //   marker     блок-маркер, скрытый от сегмента заполнивших (на «Тренингах»)
    //              — задаётся ровно одно из field / marker
    //   url        анкета, только на домене кабинета: на morozilka.site ученик
    //              приходит гостем, и ответ к нему не привяжется
    //   repeatMinutes      через сколько разворачивается после «Позже»
    //   afterClickMinutes  через сколько разворачивается после основной кнопки
    //   text       тексты модалки и полосы, у каждого баннера свои;
    //              пустой later / decline — нет кнопки
    //
    // Баннеров может быть сколько угодно. Порядок записей — приоритет при
    // первом показе: когда модалку хотят несколько баннеров сразу, её получает
    // тот, что выше. Остальные встают полосами.
    var BANNERS = [
        {
            // На обкатке: видят только сотрудники. Когда блок-маркер встанет
            // на «Тренинги» и баннер проверен — переключить на 'users'.
            key: 'parent-data',
            enabled: true,
            audience: 'users',
            trainings: 'all',
            except: [],
            marker: 'parent-data',
            url: '/user/surveyPublic/show?id=438835&hash=5f4d89b2f17970507d5578c24264dd57',
            repeatMinutes: 1440,
            afterClickMinutes: 1440,
            text: {
                title: 'Добавь контакты родителя',
                body: [
                    'Заполни телеграм или почту родителя — так он сможет получать ' +
                    'информацию о твоих успехах на курсе. Это займёт меньше минуты.'
                ],
                action: 'Заполнить данные',
                later: 'Заполнить позже',
                decline: '',
                collapsed: {
                    title: 'Добавь контакты родителя',
                    note: 'Чтобы родитель видел твой прогресс',
                    button: 'Заполнить'
                }
            }
        },
        {
            key: 'math-nps-11-11',
            enabled: false,
            audience: 'users',
            trainings: [935754477],
            except: [],
            marker: 'math-nps',
            // field: 'Опрос_математика',
            url: '/user/surveyPublic/show?id=426517&hash=769a96ac1706df4c6cb71529435d9799',
            repeatMinutes: 1440,
            afterClickMinutes: 1440,
            text: {
                title: 'Привет, на связи команда Морозилки!',
                body: [
                    'Нам важно знать, как проходит твое обучение, чтобы сделать его еще ' +
                    'комфортнее и эффективнее. Пожалуйста, удели пару минут, чтобы ответить ' +
                    'на несколько вопросов. Это полностью анонимно — мы не передаем твои ' +
                    'ответы кураторам или преподавателям.',

                    'Твоя честная обратная связь поможет нам улучшить сервис, сделать ' +
                    'обучение еще комфортнее и эффективнее. Спасибо, что помогаешь нам ' +
                    'становиться лучше!'
                ],
                action: 'Пройти опрос',
                later: 'Пройти позже',
                decline: '',
                collapsed: {
                    title: 'Расскажи, как проходит обучение',
                    note: 'Пара минут, полностью анонимно',
                    button: 'Пройти опрос'
                }
            }
        },
        // Блок с полем лежит в описании курса «Лёгкий старт» (935754631),
        // поэтому здесь указан он. Если понадобится накрыть весь предмет
        // разом, блок переносится на родителя 934668452, и сюда встаёт его
        // id — id в конфиге и место блока обязаны совпадать.
        {
            key: 'inf-nps-11-11',
            enabled: true,
            audience: 'users',
            trainings: [935771529],
            except: [],
            marker: 'inf-nps',
            // field: 'Опрос_информатика',
            url: '/user/surveyPublic/show?id=429570&hash=03b591028a1598116413808c7f40a775',
            repeatMinutes: 1440,
            afterClickMinutes: 1440,
            text: {
                title: 'Привет, на связи команда Морозилки!',
                body: [
                    'Нам важно знать, как проходит твое обучение, чтобы сделать его еще ' +
                    'комфортнее и эффективнее. Пожалуйста, удели пару минут, чтобы ответить ' +
                    'на несколько вопросов. Это полностью анонимно — мы не передаем твои ' +
                    'ответы кураторам или преподавателям.',

                    'Твоя честная обратная связь поможет нам улучшить сервис, сделать ' +
                    'обучение еще комфортнее и эффективнее. Спасибо, что помогаешь нам ' +
                    'становиться лучше!'
                ],
                action: 'Пройти опрос',
                later: 'Пройти позже',
                decline: '',
                collapsed: {
                    title: 'Расскажи, как проходит обучение',
                    note: 'Пара минут, полностью анонимно',
                    button: 'Пройти опрос'
                }
            }
        },
        {
            key: 'rus-nps-11-11',
            enabled: true,
            audience: 'users',
            trainings: [935779108],
            except: [],
            marker: 'rus-nps',
            // field: 'Опрос_русский',
            url: '/user/surveyPublic/show?id=426963&hash=fd9cfdd2746d681a4edbc78353fe1748',
            repeatMinutes: 1440,
            afterClickMinutes: 1440,
            text: {
                title: 'Привет, на связи команда Морозилки!',
                body: [
                    'Нам важно знать, как проходит твое обучение, чтобы сделать его еще ' +
                    'комфортнее и эффективнее. Пожалуйста, удели пару минут, чтобы ответить ' +
                    'на несколько вопросов. Это полностью анонимно — мы не передаем твои ' +
                    'ответы кураторам или преподавателям.',

                    'Твоя честная обратная связь поможет нам улучшить сервис, сделать ' +
                    'обучение еще комфортнее и эффективнее. Спасибо, что помогаешь нам ' +
                    'становиться лучше!'
                ],
                action: 'Пройти опрос',
                later: 'Пройти позже',
                decline: '',
                collapsed: {
                    title: 'Расскажи, как проходит обучение',
                    note: 'Пара минут, полностью анонимно',
                    button: 'Пройти опрос'
                }
            }
        },
        {
            key: 'phys-nps-11-11',
            enabled: true,
            audience: 'users',
            trainings: [935771053],
            except: [],
            marker: 'phys-nps',
            // field: 'Опрос_физика',
            url: '/user/surveyPublic/show?id=427542&hash=deec068cb0902688ce1c7f12aab86b2c',
            repeatMinutes: 1440,
            afterClickMinutes: 1440,
            text: {
                title: 'Привет, на связи команда Морозилки!',
                body: [
                    'Нам важно знать, как проходит твое обучение, чтобы сделать его еще ' +
                    'комфортнее и эффективнее. Пожалуйста, удели пару минут, чтобы ответить ' +
                    'на несколько вопросов. Это полностью анонимно — мы не передаем твои ' +
                    'ответы кураторам или преподавателям.',

                    'Твоя честная обратная связь поможет нам улучшить сервис, сделать ' +
                    'обучение еще комфортнее и эффективнее. Спасибо, что помогаешь нам ' +
                    'становиться лучше!'
                ],
                action: 'Пройти опрос',
                later: 'Пройти позже',
                decline: '',
                collapsed: {
                    title: 'Расскажи, как проходит обучение',
                    note: 'Пара минут, полностью анонимно',
                    button: 'Пройти опрос'
                }
            }
        }
    ];

    // Один и тот же контроллер доступен и с префиксом /pl, и без него.
    // На editMode не срабатываем: это редактор, а не глаза ученика.
    var TRAINING_RE = /^(?:\/pl)?\/teach\/control\/stream\/view\/id\/(\d+)\/?$/;
    var TRAINING_HREF_RE = /\/teach\/control\/stream\/view\/id\/(\d+)/;
    var LESSON_RE = /^(?:\/pl)?\/teach\/control\/lesson\/view(?:\/id\/\d+)?(?:\/editMode\/\d+)?\/?$/;
    // Урок в мобильном приложении. Хлебных крошек на нём нет, поэтому сюда
    // доходят только баннеры с trainings: 'all'.
    var WEBVIEW_RE = /^(?:\/pl)?\/teach\/control\/lesson\/webview(?:\/id\/\d+)?\/?$/;
    // Страница «Тренинги» живёт под тремя адресами. Её редактор открывается
    // по ?editMode=1 в query, поэтому search проверяется отдельно.
    var LIST_RE = /^(?:\/pl)?\/teach\/control(?:\/stream(?:\/index)?)?\/?$/;

    function matchPage(pathname, search) {
        var m = TRAINING_RE.exec(pathname);
        if (m) return { kind: 'training', id: m[1] };
        if (LESSON_RE.test(pathname)) return { kind: 'lesson' };
        if (WEBVIEW_RE.test(pathname)) return { kind: 'webview' };
        if (LIST_RE.test(pathname)) {
            return /[?&]editMode=1(?:&|$)/.test(search || '') ? null : { kind: 'list' };
        }
        return null;
    }

    // Ключи — имена сущностей без «&» и «;». Целиком сущность в исходнике
    // писать нельзя: редактор файлов темы GetCourse раскодирует её при
    // сохранении, и строка превращается в синтаксическую ошибку
    // (случилось 11.11.2026 — скрипт молчал на всех страницах).
    var ENTITIES = { amp: '&', quot: '"', lt: '<', gt: '>', '#039': "'", '#39': "'" };

    function decodeEntities(s) {
        return s.replace(/&(amp|quot|lt|gt|#0?39);/g, function (e, name) { return ENTITIES[name]; });
    }

    // Блоки-флаги. У NPS это значение доп. поля (data-field + data-value),
    // у анкеты родителя — сам факт, что блок есть (data-marker): сервер
    // вырезает его из HTML у тех, кто попал в сегмент заполнивших.
    //
    // Разбираем регуляркой по строке, а не через DOM: та же функция работает
    // над fetch'нутым HTML чужой страницы и в тестах под Node, где DOM нет.
    // [^>] захватывает и переносы строк — в боевом кабинете блок вставлен
    // в три строки. \b на границах класса, чтобы js-survey-flagged не сошёл
    // за свой.
    function parseFlags(html) {
        var tagRe = /<div\b[^>]*\bclass="[^"]*\bjs-survey-flag\b[^"]*"[^>]*>/g;
        var out = { fields: {}, markers: {} };
        var tag;
        while ((tag = tagRe.exec(html))) {
            var marker = /\bdata-marker="([^"]*)"/.exec(tag[0]);
            if (marker) {
                out.markers[decodeEntities(marker[1])] = true;
                continue;
            }
            var field = /\bdata-field="([^"]*)"/.exec(tag[0]);
            if (!field) continue;
            var value = /\bdata-value="([^"]*)"/.exec(tag[0]);
            // Блок без data-value — сломанная разметка, а не «поле пустое».
            out.fields[decodeEntities(field[1])] = value ? decodeEntities(value[1]) : null;
        }
        return out;
    }

    // Предки текущей страницы, от корня вниз, — из контейнера хлебных крошек:
    // ul.breadcrumb на уроке, div.breadcrumbs на странице тренинга.
    //
    // Читаем через DOM, а не регуляркой по innerHTML. Разметку разбирает браузер,
    // и разом отпадают три проблемы: не надо сериализовать всю страницу в строку
    // (а это сотни килобайт), не надо вырезать <script>, чей исходный текст
    // попадает в innerHTML и однажды уже сошёл за разметку, и не надо угадывать
    // границы контейнера.
    //
    // Брать ссылки по всему документу нельзя: в таблице содержания перечислены
    // вложенные модули, и потомок сошёл бы за «более глубокого» предка — на
    // родительской странице показался бы чужой опрос. Контейнер крошек это
    // отсекает по построению.
    function readTrainingIds(root) {
        var box = root.querySelector('ul.breadcrumb, div.breadcrumbs');
        if (!box) return [];

        var out = [];
        var links = box.querySelectorAll('a[href]');
        for (var i = 0; i < links.length; i++) {
            var m = TRAINING_HREF_RE.exec(links[i].getAttribute('href') || '');
            if (m) out.push(m[1]);
        }
        return out;
    }

    // Те же флаги из готового документа. Значение поля null означает, что
    // атрибута data-value нет вовсе, то есть разметка сломана, а не «поле пустое».
    function readFlags(root) {
        var out = { fields: {}, markers: {} };
        var nodes = root.querySelectorAll('.js-survey-flag');
        for (var i = 0; i < nodes.length; i++) {
            var marker = nodes[i].getAttribute('data-marker');
            if (marker) {
                out.markers[marker] = true;
                continue;
            }
            var field = nodes[i].getAttribute('data-field');
            if (field) out.fields[field] = nodes[i].getAttribute('data-value');
        }
        return out;
    }

    // Откуда брать флаги записи на этой странице.
    //   'list'  — страница «Тренинги»: для trainings: 'all'
    //   'NNN'   — тренинг из конфига: самый глубокий из цепочки, потому что
    //             блок лежит именно у него, а мы можем быть в потомке
    //   null    — записи здесь не место
    // except действует на всю цепочку: исключённый тренинг уводит с собой
    // всех потомков, как и настройка тренинга в trainings.
    function pickSource(banner, ids) {
        if (!banner.enabled) return null;

        var except = banner.except || [];
        for (var i = 0; i < ids.length; i++) {
            if (except.indexOf(Number(ids[i])) !== -1) return null;
        }

        if (banner.trainings === 'all') return 'list';

        var found = null;
        for (var j = 0; j < ids.length; j++) {
            if (banner.trainings.indexOf(Number(ids[j])) !== -1) found = String(ids[j]);
        }
        return found;
    }

    // Кому скрипт вообще разрешено показывать себя. Сотрудник — тот, у кого
    // window.userInfo помечен isAdmin, isTeacher или isManager.
    var AUDIENCES = ['users', 'admins', 'all'];

    var DEFAULT_MINUTES = 1440;   // сутки, если в записи не задано своё
    var MIN_MS = 60 * 1000;

    function verdict(show, reason, fault, dueAt) {
        return { show: show, reason: reason, fault: !!fault, dueAt: dueAt === undefined ? null : dueAt };
    }

    function minutesOf(banner, by) {
        var m = by === 'click' ? banner.afterClickMinutes : banner.repeatMinutes;
        return typeof m === 'number' ? m : DEFAULT_MINUTES;
    }

    function dueOf(state, banner) {
        return state.at + minutesOf(banner, state.by) * MIN_MS;
    }

    function isName(s) {
        return typeof s === 'string' && s !== '';
    }

    // Правило намеренно закрытое: показываем, только когда флаг однозначно
    // говорит «не заполнено». Если подстановка сломалась, дешевле недобрать
    // ответов, чем разослать баннер всем подряд, включая заполнивших.
    //
    // dueAt — когда баннеру пора в модалку. По нему arrange выбирает, кому
    // из нескольких достанется единственная модалка: раньше срок — раньше показ.
    function decide(input) {
        var config = input.config;
        var banner = input.banner;

        if (!config.enabled) return verdict('none', 'config-disabled');

        // Аудитория своя у каждого баннера: новый обкатывают на сотрудниках,
        // пока остальные уже показываются ученикам. Опечатка или пропуск не
        // должны оборачиваться показом всем подряд — это поломка, а не дефолт.
        var audience = banner.audience;
        if (AUDIENCES.indexOf(audience) === -1) {
            return verdict('none', 'audience-invalid', true);
        }
        if (audience === 'users' && input.isStaff) {
            return verdict('none', 'staff-excluded');
        }
        if (audience === 'admins' && !input.isStaff) {
            return verdict('none', 'student-excluded');
        }

        // Ровно одно из двух: доп. поле или маркер. Иначе непонятно, что
        // считать «заполнено».
        var byField = isName(banner.field);
        var byMarker = isName(banner.marker);
        if (byField === byMarker) return verdict('none', 'config-invalid', true);

        var flags = input.flags;
        if (flags === null) return verdict('none', 'flags-unavailable', true);

        if (byMarker) {
            // Блока нет — сервер вырезал его у сегмента заполнивших.
            // Забытый блок выглядит так же, но это безопасная сторона ошибки.
            if (!flags.markers[banner.marker]) return verdict('none', 'already-passed');
        } else {
            if (!Object.prototype.hasOwnProperty.call(flags.fields, banner.field)) {
                return verdict('none', 'flag-missing', true);
            }
            var value = flags.fields[banner.field];
            if (value === null) return verdict('none', 'flag-broken', true);
            if (/[{}]/.test(value)) return verdict('none', 'flag-literal', true);
            if (value !== '') return verdict('none', 'already-passed');
        }

        var state = input.state;
        if (state && state.by === 'decline') return verdict('none', 'declined');
        if (!state) return verdict('modal', 'first-time', false, 0);

        var due = dueOf(state, banner);
        if (input.now >= due) return verdict('modal', 'timer-expired', false, due);
        return verdict('strip', 'waiting', false, due);
    }

    // Сколько осталось до разворота полосы. Ноль — разворачивать пора.
    function remainingMs(state, banner, now) {
        if (!state) return 0;
        return Math.max(0, dueOf(state, banner) - now);
    }

    // Модалка на странице одна. Её получает тот, кому раньше подошёл срок,
    // при равенстве (первый показ у всех — срок 0) — тот, что выше в конфиге.
    // Проигравшие становятся полосами без таймера: состояние у них не
    // сохраняется, и на следующей странице они снова претендуют на модалку.
    // Так окна не идут одно за другим на одной странице.
    function arrange(items) {
        var modal = null;
        items.forEach(function (it) {
            if (it.verdict.show !== 'modal') return;
            if (!modal ||
                it.verdict.dueAt < modal.verdict.dueAt ||
                (it.verdict.dueAt === modal.verdict.dueAt && it.index < modal.index)) {
                modal = it;
            }
        });

        var strips = items
            .filter(function (it) { return it !== modal; })
            .map(function (it) {
                return Object.assign({}, it, { timer: it.verdict.show === 'strip' });
            })
            .sort(byIndex);

        return {
            modal: modal ? Object.assign({}, modal, { timer: false }) : null,
            strips: strips
        };
    }

    function byIndex(a, b) {
        return a.index - b.index;
    }

    var LOG_PREFIX = '[survey-banner]';

    function report(banner, v) {
        // Поломку печатаем всегда: неисправность не должна выглядеть как штатная
        // тишина. Шума не будет — на посторонних страницах мы вышли раньше.
        if (v.fault) console.warn(LOG_PREFIX, banner.key, 'поломка:', v.reason);
        else if (CONFIG.debug) console.log(LOG_PREFIX, banner.key, v.reason);
    }

    function isStaff() {
        var info = window.userInfo;
        if (!info) return false;
        return !!(info.isAdmin || info.isTeacher || info.isManager);
    }

    // id пользователя в ключе, чтобы на общем компьютере состояния не пересекались
    function stateKey(key) {
        return 'gcSurveyBanner:' + (window.accountUserId || 0) + ':' + key;
    }

    function loadState(key) {
        try {
            var raw = localStorage.getItem(stateKey(key));
            if (!raw) return null;
            var parsed = JSON.parse(raw);
            return typeof parsed.at === 'number' ? parsed : null;
        } catch (e) {
            return null;
        }
    }

    function saveState(key, by) {
        try {
            localStorage.setItem(stateKey(key), JSON.stringify({ at: Date.now(), by: by }));
        } catch (e) {
            // приватный режим или переполнение — не повод ронять баннер
        }
    }

    var LIST_URL = '/teach/control/stream/index';

    // Флаги источника тянем один раз за сессию: подстановка на странице
    // серверная, ответ у каждого свой, дольше кэшировать нельзя. В кэше лежит
    // разобранный результат, а не HTML: «Тренинги» весят ~240 КБ.
    // fresh=true обходит кэш — так перепроверяем флаг перед разворотом полосы:
    // ученик мог заполнить анкету в соседней вкладке.
    // flags2 в ключе: у прошлой версии в кэше лежал другой формат.
    function fetchFlags(source, fresh) {
        var cacheKey = 'gcSurveyBanner:flags2:' + source;
        try {
            var cached = fresh ? null : sessionStorage.getItem(cacheKey);
            if (cached) return Promise.resolve(JSON.parse(cached));
        } catch (e) { /* кэш необязателен */ }

        var url = source === 'list' ? LIST_URL : '/teach/control/stream/view/id/' + source;
        return fetch(url, { credentials: 'same-origin' })
            .then(function (res) {
                if (!res.ok) throw new Error('HTTP ' + res.status);
                return res.text();
            })
            .then(function (html) {
                var flags = parseFlags(html);
                try {
                    sessionStorage.setItem(cacheKey, JSON.stringify(flags));
                } catch (e) { /* кэш необязателен */ }
                return flags;
            })
            .catch(function () {
                return null;   // decide превратит это в поломку flags-unavailable
            });
    }

    // Источник совпал с текущей страницей — читаем документ, иначе сервер.
    // При перепроверке по таймеру документ устарел: идём на сервер в любом случае.
    function loadFlags(page, source, fresh) {
        var here = (source === 'list' && page.kind === 'list') ||
                   (page.kind === 'training' && source === page.id);
        if (here && !fresh) return Promise.resolve(readFlags(document));
        return fetchFlags(source, fresh);
    }

    function decideFor(banner, flags) {
        return decide({
            config: CONFIG,
            banner: banner,
            flags: flags,
            state: loadState(banner.key),
            now: Date.now(),
            isStaff: isStaff()
        });
    }

    function el(tag, className, text) {
        var node = document.createElement(tag);
        if (className) node.className = className;
        if (text) node.textContent = text;
        return node;
    }

    // Боковое меню кабинета фиксированное и шириной 75px — свёрнутая полоса
    // иначе заезжает под него и сливается, оба тёмно-синие. На мобильном то же
    // меню становится верхней полосой во всю ширину и левое место не занимает,
    // поэтому смотрим не на ширину экрана, а на форму самого меню.
    function updateStripOffset() {
        var bar = document.querySelector('.gc-account-leftbar');
        if (!bar) return;
        var rect = bar.getBoundingClientRect();
        if (rect.height <= rect.width) return;   // горизонтальная шапка, не колонка
        document.documentElement.style.setProperty('--gcsb-strip-left', (rect.right + 20) + 'px');
    }

    var ROOT_ID = 'gc-survey-banner';

    var currentPage = null;
    // Что сейчас на экране. Запись — Entry из arrange: { banner, index,
    // source, verdict, timer }. timer: false — полоса ждёт следующей страницы.
    var view = { modal: null, strips: [] };
    var timers = {};   // key → id setTimeout

    // Полосы с таймером должны развернуться сами, не дожидаясь перехода на
    // другую страницу. Пересчитываем после каждой перерисовки: ставим
    // недостающие и снимаем лишние.
    function syncTimers() {
        var keep = {};
        view.strips.forEach(function (entry) {
            if (!entry.timer) return;
            var key = entry.banner.key;
            keep[key] = true;
            if (timers[key]) return;
            var ms = remainingMs(loadState(key), entry.banner, Date.now());
            timers[key] = setTimeout(function () {
                delete timers[key];
                onTimer(entry);
            }, Math.max(ms, 1000));
        });
        Object.keys(timers).forEach(function (key) {
            if (keep[key]) return;
            clearTimeout(timers[key]);
            delete timers[key];
        });
    }

    function clearTimer(key) {
        if (!timers[key]) return;
        clearTimeout(timers[key]);
        delete timers[key];
    }

    function dropEntry(entry) {
        if (view.modal === entry) view.modal = null;
        view.strips = view.strips.filter(function (s) { return s !== entry; });
        clearTimer(entry.banner.key);
    }

    // Перед разворотом перечитываем флаг с сервера: анкету могли заполнить
    // в соседней вкладке, и тогда баннер надо убрать, а не звать снова.
    function onTimer(entry) {
        loadFlags(currentPage, entry.source, true).then(function (flags) {
            var v = decideFor(entry.banner, flags);
            report(entry.banner, v);
            if (view.strips.indexOf(entry) === -1) return;   // пока ждали, полосу открыли руками

            if (v.show === 'none') {
                dropEntry(entry);
            } else if (v.show === 'modal' && !view.modal) {
                dropEntry(entry);
                view.modal = entry;
            } else if (v.show === 'modal') {
                // Модалка занята другим баннером — ждём следующей страницы,
                // иначе окна пойдут одно за другим.
                entry.timer = false;
            }
            // 'strip' — срок сдвинули в другой вкладке, syncTimers поставит новый
            render();
        });
    }

    // Свернуть в полосу с таймером: by = 'close' («Позже», крестик, клик мимо
    // окна) или 'click' (основная кнопка).
    function collapse(entry, by) {
        saveState(entry.banner.key, by);
        dropEntry(entry);   // снимает и старый таймер: срок теперь другой
        entry.timer = true;
        view.strips.push(entry);
        view.strips.sort(byIndex);
        render();
    }

    // Клик по полосе. Открытую модалку другого баннера сворачиваем как «Позже»:
    // две модалки разом не показываем.
    function openModal(entry) {
        var current = view.modal;
        if (current === entry) return;
        if (current) {
            saveState(current.banner.key, 'close');
            view.modal = null;
            current.timer = true;
            view.strips.push(current);
        }
        dropEntry(entry);
        view.strips.sort(byIndex);
        view.modal = entry;
        render();
    }

    function decline(entry) {
        saveState(entry.banner.key, 'decline');
        dropEntry(entry);
        render();
    }

    // Основная кнопка — ссылка. stopPropagation на ней ставить нельзя: в
    // мобильном приложении ссылки открывает его собственный обработчик на
    // document (window.appHandleAction). Сворачиваем на следующем тике, чтобы
    // клик успел дойти до него, пока ссылка ещё в документе.
    function actionLink(entry, label) {
        var a = el('a', 'gcsb-btn', label);
        a.href = entry.banner.url;
        a.target = '_blank';
        a.rel = 'noopener';
        a.addEventListener('click', function () {
            setTimeout(function () { collapse(entry, 'click'); }, 0);
        });
        return a;
    }

    function secondaryButton(label, action, onClick) {
        var b = el('button', 'gcsb-btn is-secondary', label);
        b.type = 'button';
        b.setAttribute('data-action', action);
        b.addEventListener('click', onClick);
        return b;
    }

    function buildModal(entry) {
        var t = entry.banner.text;
        var layer = el('div', 'gcsb-modal');
        layer.setAttribute('data-key', entry.banner.key);
        // Клик по затемнению вокруг окна — то же, что «Позже»
        layer.addEventListener('click', function (e) {
            if (e.target === layer) collapse(entry, 'close');
        });

        var card = el('div', 'gcsb-card');
        var close = el('button', 'gcsb-close', '×');
        close.type = 'button';
        close.setAttribute('aria-label', 'Свернуть');
        close.addEventListener('click', function () { collapse(entry, 'close'); });
        card.appendChild(close);

        card.appendChild(el('h3', 'gcsb-title', t.title));
        t.body.forEach(function (p) { card.appendChild(el('p', 'gcsb-text', p)); });

        var actions = el('div', 'gcsb-actions');
        actions.appendChild(actionLink(entry, t.action));
        if (t.later) {
            actions.appendChild(secondaryButton(t.later, 'later', function () { collapse(entry, 'close'); }));
        }
        if (t.decline) {
            actions.appendChild(secondaryButton(t.decline, 'decline', function () { decline(entry); }));
        }
        card.appendChild(actions);

        layer.appendChild(card);
        return layer;
    }

    // Крестика в полосе нет: сворачивать уже свёрнутое некуда.
    function buildStrip(entry) {
        var t = entry.banner.text.collapsed;
        var strip = el('div', 'gcsb-strip');
        strip.setAttribute('data-key', entry.banner.key);

        var card = el('div', 'gcsb-card');
        var texts = el('div', 'gcsb-strip-text');
        texts.appendChild(el('div', 'gcsb-title', t.title));
        texts.appendChild(el('div', 'gcsb-note', t.note));
        card.appendChild(texts);
        card.appendChild(actionLink(entry, t.button));

        // Клик по полосе разворачивает модалку. Клик по кнопке сюда тоже
        // всплывает — его пропускаем, а не глушим (см. actionLink).
        card.addEventListener('click', function (e) {
            if (e.target.closest('.gcsb-btn')) return;
            openModal(entry);
        });

        strip.appendChild(card);
        return strip;
    }

    // Перерисовываем корень целиком: так повторный запуск скрипта не
    // плодит второй баннер, а состояние живёт только в view.
    function render() {
        var old = document.getElementById(ROOT_ID);
        if (old) old.parentNode.removeChild(old);
        syncTimers();

        if (!view.modal && !view.strips.length) return;
        updateStripOffset();

        var root = el('div');
        root.id = ROOT_ID;
        if (view.modal) root.appendChild(buildModal(view.modal));
        if (view.strips.length) {
            // Незнакомое значение — столбец: так полосы точно не уедут за экран
            var stack = el('div', 'gcsb-strips ' + (CONFIG.strips === 'row' ? 'is-row' : 'is-column'));
            view.strips.forEach(function (entry) { stack.appendChild(buildStrip(entry)); });
            root.appendChild(stack);
        }
        document.body.appendChild(root);
    }

    function run(page) {
        // Цепочка тренингов от корня вниз. На «Тренингах» и в приложении
        // крошек нет — туда доходят только баннеры с trainings: 'all'.
        var ids = page.kind === 'training' || page.kind === 'lesson' ? readTrainingIds(document) : [];
        if (page.kind === 'training') ids.push(page.id);

        var picked = [];
        BANNERS.forEach(function (banner, index) {
            var source = pickSource(banner, ids);
            if (source) picked.push({ banner: banner, index: index, source: source });
            else if (CONFIG.debug && banner.enabled) console.log(LOG_PREFIX, banner.key, 'no-training');
        });
        if (!picked.length) return;

        // Записи с общим источником делят один запрос
        var loads = {};
        picked.forEach(function (p) {
            if (!loads[p.source]) loads[p.source] = loadFlags(page, p.source, false);
        });
        var sources = Object.keys(loads);

        Promise.all(sources.map(function (s) { return loads[s]; })).then(function (results) {
            var flagsBy = {};
            sources.forEach(function (s, i) { flagsBy[s] = results[i]; });

            var items = [];
            picked.forEach(function (p) {
                var v = decideFor(p.banner, flagsBy[p.source]);
                report(p.banner, v);
                if (v.show !== 'none') {
                    items.push({ banner: p.banner, index: p.index, source: p.source, verdict: v });
                }
            });

            var layout = arrange(items);
            view = { modal: layout.modal, strips: layout.strips };
            render();
        });
    }

    function init() {
        if (!CONFIG.enabled) return;
        currentPage = matchPage(location.pathname, location.search);
        if (!currentPage) return;
        run(currentPage);
    }

    if (typeof module !== 'undefined' && module.exports) {
        // Node: чистые функции для тестов и BANNERS для live-check.js
        module.exports = { matchPage, parseFlags, pickSource, decide, remainingMs, arrange, BANNERS: BANNERS };
    } else if (matchPage(location.pathname, location.search)) {
        // Из <head> DOM ещё не разобран: на верхнем уровне трогаем только location
        if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
        else init();
    }
})();
