#!/usr/bin/env python3
"""Свести контент-план с тем, что реально написано и вышло.

Очередь волны и баланс типов набивались руками 28.08.2026, а потом статьи
выходили, и план остался на цифрах первой недели: в панели пятая статья,
на портале десятая. Скрипт закрывает разрыв в одну сторону, из реестра
текстов в план, и трогает только то, что вычисляется: статус позиции,
счётчики баланса, ноту под ним и полосы недели.

Темы, которых в плане нет вовсе (вышли вне очереди волны), дописываются
в конец очереди с неделей «вне плана»: иначе опубликованная статья не
видна в разделе, где её ищут.

Запуск: python3 панель/данные/обновить-контент-план.py
Перед запуском: python3 завод/реестр-текстов.py
"""

import json
import re
import subprocess
from datetime import date, timedelta
from pathlib import Path

ДАННЫЕ = Path(__file__).resolve().parent
КОРЕНЬ = ДАННЫЕ.parent.parent
ПЛАН = ДАННЫЕ / "contentplan.json"
РЕЕСТР = КОРЕНЬ / "завод" / "реестр-текстов.json"
ПЛОЩАДКИ = КОРЕНЬ / "завод" / "площадки.json"
ЯДРО = КОРЕНЬ / "семантика" / "ядро.json"
ПЛАН_ВОЛНЫ = КОРЕНЬ / "семантика" / "контент-план-волна-1.md"
ПЕРЕЧЕНЬ = ДАННЫЕ / "platforms.json"

# строка расписания плана: «| 16.09 ср | **А20** Отзывы… | франшизы | 2 899 | в кроне, 11:00 |»
СТРОКА_РАСПИСАНИЯ = re.compile(
    r"^\|\s*(\d{2})\.(\d{2})\s+(\S+)\s*\|\s*\*\*([А-ЯЁA-Z]\d+)\*\*\s*([^|]*)\|"
    r"\s*([^|]*)\|[^|]*\|\s*([^|]*)\|", re.M)

# площадка считается заведённой по началу статуса: «заведено» сидит подстрокой
# внутри «не заведено», и поиск по вхождению затаскивает в счёт пустые строки
НАЧАЛО_РАБОТЫ = ("работает", "заведено", "аккаунт", "регистрация начата",
                 "отправлено", "домен подключён")



# --- Календарная сетка: площадки слева, дни сверху -------------------------
# Снято с эталона PR Factory (mybiusty.prfabrika.ru/content-plan) 17.09.2026
# по просьбе руководителя. Оттуда взято устройство: сетка «площадка x день»,
# полоса недель с числами «вышло / по плану», разные ячейки для вышедшего и
# запланированного, список ожидающих работы под сеткой.
# Чем отличаемся и почему:
#  - у эталона в каждой плановой ячейке кнопка «Сгенерировать», у нас панель
#    ничего не запускает и тексты по кнопке не пишутся: ячейка ведёт на текст;
#  - у эталона план раскладывает движок, у нас даты портала стоят в кроне и в
#    плане волны - плановые ячейки берём оттуда, а не вычисляем;
#  - первой строкой идёт портал: у эталона все площадки внешние, а у нас
#    собственный сайт - главный контур, и в сетке он должен быть виден первым.
ПОРТАЛ = "Портал frantlas.ru"
ДНИ_НЕДЕЛИ = ("пн", "вт", "ср", "чт", "пт", "сб", "вс")


def дата_из_текста(текст):
    """Дата из статуса. Форматов два: у площадок «17.09.2026», у портала
    «2026-09-17». Второй сначала не разбирался, и сегодняшняя статья портала
    показывалась в сетке как план, хотя уже вышла (17.09.2026)."""
    т = текст or ""
    м = re.search(r"(\d{4})-(\d{2})-(\d{2})", т)
    if м:
        г, мес, д = м.group(1), м.group(2), м.group(3)
    else:
        м = re.search(r"(\d{2})\.(\d{2})\.(\d{4})", т)
        if not м:
            return None
        д, мес, г = м.group(1), м.group(2), м.group(3)
    try:
        return date(int(г), int(мес), int(д))
    except ValueError:
        return None


def вышедшее():
    """Что уже вышло: дата, площадка или портал, заголовок, ссылка."""
    if not РЕЕСТР.exists():
        return []
    р = json.loads(РЕЕСТР.read_text(encoding="utf-8"))
    итог = []
    for т in р.get("тексты", []):
        статус = str(т.get("статус") or "")
        if "опубликован" not in статус.lower() or "не видно читателям" in статус.lower():
            continue
        когда = дата_из_текста(статус)
        if not когда:
            continue
        внешняя = т.get("вид") == "внешняя площадка"
        кто = (т.get("площадка") or "").split("(")[0].strip() if внешняя else ПОРТАЛ
        итог.append({"дата": когда.isoformat(), "кто": кто or ПОРТАЛ,
                     "тема": т.get("заголовок") or "", "вышла": True,
                     "ссылка": т.get("ссылка") or None, "нота": ""})
    return итог


def на_модерации():
    """Что ушло на модерацию: ставим в день отправки (просьба 02.10.2026).

    Берём тексты со статусом «отправлено ...» - на модерацию, в редакцию, на
    проверку. Дата - первая в статусе, это день отправки; позже в том же
    статусе бывает дата напоминания. Когда текст выйдет или его отклонят,
    статус в реестре сменится, и карточка из дня отправки уйдёт сама.
    """
    if not РЕЕСТР.exists():
        return []
    р = json.loads(РЕЕСТР.read_text(encoding="utf-8"))
    итог = []
    for т in р.get("тексты", []):
        статус = str(т.get("статус") or "")
        if not статус.lower().startswith("отправлено"):
            continue
        когда = дата_из_текста(статус)
        if not когда:
            continue
        кто = (т.get("площадка") or "").split("(")[0].strip() or "Без площадки"
        итог.append({"дата": когда.isoformat(), "кто": кто,
                     "тема": т.get("заголовок") or "", "вышла": False,
                     "модерация": True, "ссылка": None,
                     "нота": "на модерации"})
    return итог


def плановое(ряд, уже):
    """Плановые дни портала из расписания волны.

    Год в плане не пишется, берём текущий. Ячейку не ставим там, где в этот
    день на портале статья уже вышла: иначе план спорит с фактом.
    """
    занято = {(с["дата"], с["кто"]) for с in уже}
    сегодня = date.today()
    итог = []
    for п in ряд:
        части = (п.get("дата") or "").split(".")
        if len(части) != 2:
            continue
        try:
            когда = date(date.today().year, int(части[1]), int(части[0]))
        except ValueError:
            continue
        если = (когда.isoformat(), ПОРТАЛ)
        # в дни, где уже вышли статьи, слот 17:00 ещё может быть впереди,
        # поэтому сверяем по числу вышедших, а не по факту «что-то вышло»
        if когда < сегодня or (если in занято and п.get("пометка") == "вышла"):
            continue
        итог.append({"дата": когда.isoformat(), "кто": ПОРТАЛ,
                     "тема": " ".join(x for x in (п.get("код"), п.get("тема")) if x),
                     "вышла": False, "ссылка": None,
                     "нота": ", ".join(x for x in (п.get("время"), п.get("пометка"))
                                       if x and x not in п.get("пометка", "")[-5:]) or ""})
    return итог


# Норма выпуска на внешние площадки: решение руководителя 17.09.2026 -
# понедельник-пятница, 3-5 статей в день, и это РАЗНЫЕ площадки, а не одна.
# Второе ограничение уже было у нас правилом работы: на одну площадку не
# больше публикации в сутки, второй материал откладываем на следующий день.
НОРМА_ПЛОЩАДОК = {"мин": 3, "макс": 5, "дни": "будни (пн-пт)",
                  "правило": "разные площадки: на одну площадку не больше одной публикации в день"}
ОЧЕРЕДЬ_РУЧНАЯ = ДАННЫЕ / "manualqueue.json"


def будни_от(старт, сколько):
    """Ближайшие рабочие дни, начиная со следующего за стартом."""
    ряд, д = [], старт
    while len(ряд) < сколько:
        д += timedelta(days=1)
        if д.weekday() < 5:
            ряд.append(д)
    return ряд


def раскладка_площадок():
    """Готовые к отправке материалы - по ближайшим будним дням.

    Раскладываем только то, что действительно готово: панель не придумывает
    статьи под норму. Если материалов меньше нормы, это видно на сетке -
    день стоит недогруженным, и это честный ответ «писать нечего», а не
    нарисованный план.
    """
    if not ОЧЕРЕДЬ_РУЧНАЯ.exists():
        return [], {}
    о = json.loads(ОЧЕРЕДЬ_РУЧНАЯ.read_text(encoding="utf-8")).get("очередь", [])
    готовые = [с for с in о if с.get("статус") == "Готов к отправке"]
    if not готовые:
        return [], {"готовых": 0}
    сегодня = date.today()
    дни = будни_от(сегодня, 10)
    занято = {}          # день -> {площадки}
    в_день = {}          # день -> сколько поставлено
    слоты = []
    # Материал с датой в поле «когда» (сегодня или позже) стоит на свою дату:
    # это план дня, его ставит человек. Раньше поле не читалось, и план
    # на сегодня уезжал на завтра (05.10.2026). Остальное - по будням с завтра.
    def своя_дата(с):
        try:
            д = date.fromisoformat((с.get("когда") or "")[:10])
        except ValueError:
            return None
        return д if д >= сегодня else None
    готовые.sort(key=lambda с: своя_дата(с) is None)
    for с in готовые:
        площадка = с.get("площадка") or ""
        д = своя_дата(с)
        if д and площадка not in занято.get(д.isoformat(), set()):
            к = д.isoformat()
            занято.setdefault(к, set()).add(площадка)
            в_день[к] = в_день.get(к, 0) + 1
            слоты.append({"дата": к, "кто": площадка,
                          "тема": с.get("статья") or "", "вышла": False,
                          "ссылка": None,
                          "нота": "в плане на этот день, публикуем руками"})
            continue
        for д in дни:
            к = д.isoformat()
            if в_день.get(к, 0) >= НОРМА_ПЛОЩАДОК["макс"]:
                continue
            if площадка in занято.get(к, set()):
                continue      # на эту площадку сегодня уже ставили
            занято.setdefault(к, set()).add(площадка)
            в_день[к] = в_день.get(к, 0) + 1
            слоты.append({"дата": к, "кто": площадка,
                          "тема": с.get("статья") or "", "вышла": False,
                          "ссылка": None,
                          "нота": "готов к отправке, публикуем руками"})
            break
    хватает = sum(1 for д in ([сегодня] if сегодня.weekday() < 5 else []) + дни[:4]
                  if в_день.get(д.isoformat(), 0) >= НОРМА_ПЛОЩАДОК["мин"])
    return слоты, {"готовых": len(готовые), "дней_по_норме": хватает,
                   "рабочих_дней_впереди": 5}


def недели_сетки(события):
    """Понедельники недель, в которых что-то есть, с числами вышло/план."""
    по_неделям = {}
    for с in события:
        д = date.fromisoformat(с["дата"])
        старт = д - timedelta(days=д.weekday())
        у = по_неделям.setdefault(старт, {"вышло": 0, "план": 0, "модерация": 0})
        у["вышло" if с["вышла"] else "модерация" if с.get("модерация") else "план"] += 1
    ряд = []
    for старт in sorted(по_неделям):
        конец = старт + timedelta(days=6)
        ряд.append({
            "ключ": старт.isoformat(),
            "подпись": (f"{старт.day}-{конец.day} {МЕСЯЦЫ[конец.month - 1]}"
                        if старт.month == конец.month else
                        f"{старт.day} {МЕСЯЦЫ[старт.month - 1]} - "
                        f"{конец.day} {МЕСЯЦЫ[конец.month - 1]}"),
            "вышло": по_неделям[старт]["вышло"],
            "план": по_неделям[старт]["план"],
            "модерация": по_неделям[старт]["модерация"],
        })
    return ряд


МЕСЯЦЫ = ("января", "февраля", "марта", "апреля", "мая", "июня", "июля",
          "августа", "сентября", "октября", "ноября", "декабря")


def календарь(ряд):
    """Сетка: недели, дни каждой недели, строки площадок с ячейками."""
    события = вышедшее()
    события += на_модерации()
    события += плановое(ряд, события)
    слоты, свод = раскладка_площадок()
    события += слоты
    if not события:
        return {}
    недели = недели_сетки(события)
    # Строки: портал первым, дальше площадки по числу публикаций.
    счёт = {}
    for с in события:
        счёт[с["кто"]] = счёт.get(с["кто"], 0) + 1
    площадки = [ПОРТАЛ] + sorted((к for к in счёт if к != ПОРТАЛ),
                                 key=lambda к: (-счёт[к], к))
    дни = {}
    for н in недели:
        старт = date.fromisoformat(н["ключ"])
        дни[н["ключ"]] = [{"дата": (старт + timedelta(days=i)).isoformat(),
                           "подпись": f"{(старт + timedelta(days=i)).day:02d}."
                                      f"{(старт + timedelta(days=i)).month:02d}",
                           "день": ДНИ_НЕДЕЛИ[i],
                           "выходной": i >= 5}
                          for i in range(7)]
    ячейки = {}
    for с in события:
        ячейки.setdefault(с["дата"], {}).setdefault(с["кто"], []).append(
            {к: с.get(к, False) for к in ("тема", "ссылка", "вышла", "модерация", "нота")})
    сегодня = date.today()
    текущая = next((н["ключ"] for н in недели
                    if date.fromisoformat(н["ключ"]) <= сегодня
                    <= date.fromisoformat(н["ключ"]) + timedelta(days=6)),
                   недели[-1]["ключ"] if недели else None)
    # Итог по дню: сколько публикаций на внешних площадках против нормы.
    # Портал в этот счёт не входит - у него своя норма, 10 статей в неделю.
    итоги = {}
    for д, кто in ячейки.items():
        вышло = sum(1 for к in кто if к != ПОРТАЛ for я in кто[к] if я["вышла"])
        # отправка на модерацию - тоже выпуск этого дня, в норму дня входит
        модерация = sum(1 for к in кто if к != ПОРТАЛ for я in кто[к] if я["модерация"])
        план = sum(1 for к in кто if к != ПОРТАЛ for я in кто[к]
                   if not я["вышла"] and not я["модерация"])
        итоги[д] = {"вышло": вышло, "план": план, "модерация": модерация}
    return {"недели": недели, "дни": дни, "площадки": площадки,
            "ячейки": ячейки, "текущая": текущая, "сегодня": сегодня.isoformat(),
            "итоги": итоги, "норма": НОРМА_ПЛОЩАДОК, "свод": свод}


def показы_кластеров():
    """{код: показы} из ядра. В плане у части позиций показов не было вовсе,
    хотя ядро перемерено 11.09.2026 целиком: цифра есть у каждого кластера."""
    if not ЯДРО.exists():
        return {}
    я = json.loads(ЯДРО.read_text(encoding="utf-8"))
    return {к["код"]: к.get("показы") for к in я.get("кластеры", []) if к.get("код")}


def расписание():
    """Ближайшие дни выхода из таблицы плана: [(дата, день, код, тема, контур, пометка)].

    С 30.09.2026 темп 10 статей в неделю, и сетка слотов в плане записана
    иначе: «| 01.10 чт 11:00 | бизнес | 3 | Б35 Свой бизнес с нуля |». Она
    отменяет прежние таблицы дат, поэтому из старых берём только дни до
    начала сетки - там история первой волны.
    """
    if not ПЛАН_ВОЛНЫ.exists():
        return []
    т = ПЛАН_ВОЛНЫ.read_text(encoding="utf-8")
    сетка = слоты_сетки(т)
    начало = min((ключ_даты(с["дата"]) for с in сетка), default=None)
    ряд = []
    вышли = опубликованные_кластеры(json.loads(РЕЕСТР.read_text(encoding="utf-8"))) \
        if РЕЕСТР.exists() else {}
    for день, месяц, дн, код, тема, контур, пометка in СТРОКА_РАСПИСАНИЯ.findall(т):
        if начало and ключ_даты(f"{день}.{месяц}") >= начало:
            continue
        # пометку старой таблицы никто не обновляет: А27 вышла 30.09, а там «писать»
        if код in вышли and "вышла" not in пометка:
            пометка = "вышла"
        ряд.append({
            "дата": f"{день}.{месяц}",
            "день": дн.strip(),
            "время": "11:00",
            "код": код,
            "тема": " ".join(тема.split()),
            "контур": " ".join(контур.split()).capitalize(),
            "пометка": " ".join(пометка.split()),
        })
    return ряд + сетка


# слот сетки темпа 10 в неделю: «| 02.10 пт 17:00 | бизнес | 2 | Б37 Бизнес-идеи |»
СТРОКА_СЕТКИ = re.compile(
    r"^\|\s*(\d{2})\.(\d{2})\s+(\S+)\s+(\d{1,2}:\d{2})\s*\|\s*([^|]*)\|\s*([^|]*)\|\s*([^|]*)\|",
    re.M)
# код кластера в начале темы слота; «П1 из ядра» и «А21 или А22» - тема не выбрана
КОД_СЛОТА = re.compile(r"^([АБМ]\d+|СТМ)\s+(?!или\s)(.*)$")
# у текста про СТМ своего кода кластера в реестре нет (тема из видео 23.09.2026,
# спрос не замерен), поэтому связываем слот с текстом по слагу
СЛАГ_БЕЗ_КОДА = {"СТМ": "stm-vo-franshize"}
НАПОМИНАНИЯ = КОРЕНЬ / "завод" / "напоминания"


def ключ_даты(дм):
    д, м = дм.split(".")
    return (int(м), int(д))


def крон():
    """Текст crontab: задание стоит, только если оно там есть, а не просто лежит скрипт."""
    try:
        return subprocess.run(["crontab", "-l"], capture_output=True, text=True,
                              timeout=10).stdout
    except (OSError, subprocess.SubprocessError):
        return ""


def слоты_сетки(т):
    """Слоты сетки с состоянием, посчитанным по реестру, скриптам и crontab.

    Состояние не переписываем из плана словами: там пометки «готова», «писать»
    стареют за день. Смотрим сами: вышла ли статья, есть ли одноразовый скрипт
    публикации на этот слот и стоит ли он в crontab.
    """
    реестр = json.loads(РЕЕСТР.read_text(encoding="utf-8")) if РЕЕСТР.exists() else {"тексты": []}
    портал = {т_["кластер"]: т_ for т_ in реестр["тексты"]
              if т_.get("вид") == "портал" and т_.get("кластер")}
    по_слагу = {т_.get("слаг"): т_ for т_ in реестр["тексты"] if т_.get("вид") == "портал"}
    for код_, слаг_ in СЛАГ_БЕЗ_КОДА.items():
        if слаг_ in по_слагу:
            портал.setdefault(код_, по_слагу[слаг_])
    задания = крон()
    год = date.today().year
    ряд = []
    for день, месяц, дн, время, ветка_, _тип, статья in СТРОКА_СЕТКИ.findall(т):
        статья = " ".join(статья.split())
        найдено = КОД_СЛОТА.match(статья)
        if not найдено:
            код, тема = "", статья
        else:
            # пометки в скобках («готова, обложка тип 3») стареют, состояние считаем ниже
            код, тема = найдено.group(1), re.sub(r"\s*\([^)]*\)\s*$", "", найдено.group(2))
        текст = портал.get(код) or {}
        статус = (текст.get("статус") or "").lower()
        слаг = текст.get("слаг") or ""
        # скрипт ищем по дате и строке SLUG внутри: имя файла бывает короче слага
        # (у М3 «marketpleys» при слаге franshiza-ili-marketpleys)
        скрипт = next((ф for ф in sorted(НАПОМИНАНИЯ.glob(f"{год}-{месяц}-{день}-publikacia-*.sh"))
                       if слаг and f'SLUG="{слаг}"' in ф.read_text(encoding="utf-8")), None)
        if not код:
            пометка = "тема не выбрана"
        elif "опубликовано на портале" in статус:
            пометка = "вышла"
        elif скрипт and скрипт.exists() and скрипт.name in задания:
            пометка = f"в кроне, {время}"
        elif скрипт and скрипт.exists():
            пометка = "готова, крон не поставлен"
        elif статус.startswith("готова"):
            пометка = "текст готов, скрипта публикации нет"
        elif текст:
            пометка = "текст есть, скрипта публикации нет"
        else:
            пометка = "писать"
        ряд.append({
            "дата": f"{день}.{месяц}",
            "день": дн.strip(),
            "время": время,
            "код": код,
            "тема": тема,
            "контур": " ".join(ветка_.split()).capitalize(),
            "пометка": пометка,
        })
    return ряд


РЕШЕНИЕ_ТЕМПА = re.compile(r"^###\s*Темп[^\n]*решение руководителя (\d{2}\.\d{2}\.\d{4})", re.M)


def ритм():
    """Подпись темпа из самой сетки: сколько слотов в неделю и в какие часы.

    До 30.09.2026 строка «одна статья в будний день» была вписана в данные
    руками и пережила смену темпа."""
    if not ПЛАН_ВОЛНЫ.exists():
        return ""
    т = ПЛАН_ВОЛНЫ.read_text(encoding="utf-8")
    по_дням = {}
    for _д, _м, дн, время, *_ in СТРОКА_СЕТКИ.findall(т):
        по_дням.setdefault(дн, set()).add(время)
    if not по_дням:
        return ""
    # одинаковые наборы часов сводим в одну группу: «пн, ср, пт - 11:00 и 17:00»
    группы = {}
    for дн in ДНИ_НЕДЕЛИ:
        if дн in по_дням:
            группы.setdefault(tuple(sorted(по_дням[дн])), []).append(дн)
    в_неделю = sum(len(ч) for ч in по_дням.values())
    части = ["%s - %s" % (", ".join(дни), " и ".join(часы))
             for часы, дни in sorted(группы.items(), key=lambda г: -len(г[0]))]
    # Коротко с 02.10.2026 (компактный вид панели): дата решения о темпе
    # из подписи ушла, она есть в плане волны.
    return ("%d статей в неделю: %s · публикует крон"
            % (в_неделю, "; ".join(части)))


def недели_из_расписания(ряд, показы):
    """Строки раздела «Недели» - теперь это дни выхода, а не номера недель.

    До 15.09.2026 здесь лежали описания недель первой волны, набитые руками
    28.08. Темп с тех пор сменился дважды, и в панели висело расписание,
    которого нет ни в плане, ни в кроне. Собираем из плана.
    """
    # Прошедшие дни в таблицу не берём: всё вышедшее сидит в строке «уже
    # вышло», а невышедшие слоты прежнего темпа отменены (30.09.2026). Раньше
    # таблица начиналась с 14.09 и показывала отменённое расписание.
    сегодня = date.today()
    строки = []
    for э in ряд:
        if ключ_даты(э["дата"]) < (сегодня.month, сегодня.day):
            continue
        вышла = "вышла" in э["пометка"]
        в_кроне = "в кроне" in э["пометка"]
        статус = ("Закрыта" if вышла else "Готова, ждёт часа" if в_кроне
                  else "Готова, крон не поставлен" if "крон не поставлен" in э["пометка"]
                  else "Готова, скрипта нет" if "текст готов" in э["пометка"]
                  else "Пишется" if "текст есть" in э["пометка"]
                  else "Тема не выбрана" if not э["код"] else "Не начата")
        строки.append({
            "неделя": f'{э["дата"]} {э["день"]} {э.get("время", "")}'.strip(),
            "что": " ".join(x for x in (э["код"], э["тема"]) if x),
            "почему": (f'{э["контур"]}. ' if э["контур"] else "")
                      + (f'спрос {показы[э["код"]]:,} показов'.replace(",", " ")
                         if показы.get(э["код"])
                         # ноль и «нет замера» - разные вещи: ноль стоит у кластера,
                         # все ключи которого отбракованы руководителем (29.09.2026)
                         else "все ключи кластера отбракованы" if показы.get(э["код"]) == 0
                         else "частотность не мерена"),
            "статей": 1,
            "опубликовано": 1 if вышла else 0,
            "статус": статус,
            "демо": False,
        })
    return строки


def опубликованные_кластеры(реестр):
    """Кластер → заголовок для всего, что вышло на портале."""
    return {т["кластер"]: т["заголовок"] for т in реестр["тексты"]
            if "опубликовано на портале" in т["статус"].lower() and т.get("кластер")}


# Ветку определяет буква кода кластера, а не поле «контур»: там лежит роль
# позиции («спутник · разбор», «мост», «опора»), а не ветка. Из-за этого
# 15.09.2026 разбивка баланса по контурам стояла нулями.
ВЕТКИ = {"А": "Франшизы", "Б": "Бизнес", "М": "Мосты"}


def ветка(код):
    return ВЕТКИ.get(str(код or "")[:1], "")


def заведено_аккаунтов():
    """Сколько площадок заведено, по тому же перечню, что и вкладка «Площадки».

    Раньше считали по статусу в карточке («начинается с "работает"/"заведено"…»),
    и счёт врал в обе стороны: пропускал площадки со статусом «РАБОТАЕМ:» или
    «ОПУБЛИКОВАНО», зато включал наш собственный портал. 15.09.2026 сведено
    с перечнем: заведённой считается площадка в любом состоянии, кроме очереди.
    """
    if not ПЕРЕЧЕНЬ.exists():
        return None
    строки = json.loads(ПЕРЕЧЕНЬ.read_text(encoding="utf-8")).get("строки", [])
    return sum(1 for с in строки if с.get("состояние") != "В очереди")


def главная():
    план = json.loads(ПЛАН.read_text(encoding="utf-8"))
    реестр = json.loads(РЕЕСТР.read_text(encoding="utf-8"))
    вышли = опубликованные_кластеры(реестр)
    очередь = план["очередь"]
    сообщения = []

    # 1. статусы позиций
    for поз in очередь:
        if поз["кластер"] in вышли and поз["статус"] != "Опубликована":
            сообщения.append("%s %s: %s → Опубликована"
                             % (поз["кластер"], поз["тема"][:34], поз["статус"]))
            поз["статус"] = "Опубликована"

    # 2. вышедшее вне очереди волны
    в_плане = {поз["кластер"] for поз in очередь}
    for кластер, заголовок in вышли.items():
        if кластер in в_плане:
            continue
        очередь.append({"позиция": max(п["позиция"] for п in очередь) + 1,
                        "кластер": кластер, "тема": заголовок,
                        "контур": "спутник · разбор", "тип": "разбор",
                        "приоритет": "P1", "объём": "-", "неделя": "вне плана",
                        "волна": "П1", "статус": "Опубликована", "демо": False})
        сообщения.append("%s %s: дописана в очередь (вне плана)" % (кластер, заголовок[:34]))

    # 3. показы из ядра: цифра спроса есть у каждого кластера после
    # перезамера 11.09.2026, а в плане у 23 позиций стоял пропуск
    # Ядро - единственный источник частотности, поэтому перезаписываем всегда,
    # а не только когда пусто: после перезамера 11.09.2026 в плане остались
    # старые числа (у А6 стояло 5 329 вместо 1 090), и панель показывала спрос
    # прежней методики.
    показы = показы_кластеров()
    правок, добавлено_показов = 0, 0
    for поз in очередь:
        свежее = показы.get(поз.get("кластер"))
        # ноль переписываем как число: у кластера, все ключи которого
        # отбракованы, спрос по ядру и правда ноль (29.09.2026)
        if свежее is None:
            continue
        было_ = поз.get("показы")
        if было_ is None:
            добавлено_показов += 1
        elif было_ != свежее:
            правок += 1
        поз["показы"] = свежее
    if добавлено_показов or правок:
        сообщения.append("показы из ядра: проставлено %d, поправлено %d"
                         % (добавлено_показов, правок))

    # 3.5. даты и статусы позиций сверяем с расписанием плана. В очереди
    # осталось расписание прежнего темпа (две статьи в день), и даты разъехались
    # с планом на неделю: А6 стояла на 16.09, хотя вышла 14.09 (найдено 15.09.2026).
    ряд = расписание()
    по_коду = {э["код"]: э for э in ряд}
    for поз in очередь:
        э = по_коду.get(поз.get("кластер"))
        if not э:
            continue
        if поз.get("неделя") != э["дата"]:
            сообщения.append("%s: дата %s → %s" % (поз["кластер"], поз.get("неделя"), э["дата"]))
            поз["неделя"] = э["дата"]
        если_вышла = "вышла" in э["пометка"]
        стало = ("Опубликована" if (если_вышла or поз["статус"] == "Опубликована")
                 else "Запланирована" if "крон" in э["пометка"] else "В плане")
        if поз["статус"] != стало:
            сообщения.append("%s: %s → %s" % (поз["кластер"], поз["статус"], стало))
            поз["статус"] = стало

    # 3.6. поле «неделя» у остальных позиций. Там лежала смесь: номера недель
    # прежнего плана, слово «вышла», метка волны «П2». Приводим к одному виду:
    # у вышедших - дата публикации из реестра, у прочих - «очередь».
    даты_выхода = {}
    for текст in реестр["тексты"]:
        код = текст.get("кластер")
        статус_ = текст.get("статус") or ""
        if not (код and "опубликовано на портале" in статус_.lower()):
            continue
        # в реестре дата бывает в двух видах: «2026-09-14» и «14.09.2026»
        иso = re.search(r"(\d{4})-(\d{2})-(\d{2})", статус_)
        рус = re.search(r"(\d{2})\.(\d{2})\.(\d{4})", статус_)
        if иso:
            даты_выхода[код] = "%s.%s" % (иso.group(3), иso.group(2))
        elif рус:
            даты_выхода[код] = "%s.%s" % (рус.group(1), рус.group(2))
    приведено = 0
    for поз in очередь:
        если_в_расписании = поз.get("кластер") in по_коду
        if если_в_расписании:
            continue
        было_ = поз.get("неделя")
        стало_ = (даты_выхода.get(поз.get("кластер"))
                  if поз["статус"] == "Опубликована" else None) or (
                  "вышла" if поз["статус"] == "Опубликована" else "очередь")
        if было_ != стало_:
            поз["неделя"] = стало_
            приведено += 1
    if приведено:
        сообщения.append("поле «неделя» приведено к одному виду: %d позиций" % приведено)

    # 4. раздел «Недели» пересобираем из расписания плана. Раньше тут лежали
    # описания недель первой волны, набитые руками 28.08.2026; темп с тех пор
    # сменился дважды, и панель показывала расписание, которого нет ни в плане,
    # ни в кроне (замечено руководителем 15.09.2026).
    if ряд:
        вышло_всего = sum(1 for п in очередь if п["статус"] == "Опубликована")
        шапка = {
            "неделя": "уже вышло",
            "что": "%d статей на портале" % вышло_всего,
            "почему": ("накопленное с 03.09.2026, когда портал открылся. "
                       "Ниже - ближайшие слоты по расписанию, начиная с сегодняшнего дня"),
            "статей": вышло_всего,
            "опубликовано": вышло_всего,
            "статус": "Закрыта",
            "демо": False,
            # сумма, а не слот: панель рисует её отдельной строкой сверху
            "итог": True,
        }
        план["недели"] = [шапка] + недели_из_расписания(ряд, показы)
        темп = ритм()
        if темп:
            план["мета"]["ритм"] = темп
        сообщения.append("расписание пересобрано: %d дней из плана" % len(ряд))

    # 4. баланс типов: итого и опубликовано считаем по очереди, а не помним
    контуры = план["баланс"].get("контуры") or ["Франшизы", "Бизнес", "Мосты"]
    for строка in план["баланс"]["строки"]:
        свои = [п for п in очередь if п["тип"] == строка["тип"]]
        строка["итого"] = len(свои)
        строка["опубликовано"] = sum(1 for п in свои if п["статус"] == "Опубликована")
        # Разбивка по контурам тоже считается, а не помнится: 15.09.2026 в ней
        # стояли цифры 28.08, и сумма по контурам не сходилась с «итого»
        # (у разборов было 25 всего против 8 по контурам).
        for контур in контуры:
            строка[контур] = sum(1 for п in свои if ветка(п.get("кластер")) == контур)
    типы = {с["тип"] for с in план["баланс"]["строки"]}
    for поз in очередь:
        if поз["тип"] not in типы:
            план["баланс"]["строки"].append(
                {"тип": поз["тип"], "итого": sum(1 for п in очередь if п["тип"] == поз["тип"]),
                 "опубликовано": sum(1 for п in очередь
                                     if п["тип"] == поз["тип"] and п["статус"] == "Опубликована"),
                 "Франшизы": 0, "Бизнес": 0, "Мосты": 0})
            типы.add(поз["тип"])

    коды = sorted(к for к, поз in ((п["кластер"], п) for п in очередь)
                  if поз["статус"] == "Опубликована")
    вне = sorted(п["кластер"] for п in очередь
                 if п["статус"] == "Опубликована" and str(п["неделя"]) == "вне плана")
    план["баланс"]["нота"] = (
        "Опубликовано %d %s: %s.%s"
        % (len(коды), "статья" if len(коды) == 1 else "статей", ", ".join(коды),
           (" Вне очереди волны вышли %s, поэтому в разбивке по неделям их нет."
            % ", ".join(вне)) if вне else ""))

    # 4. полосы. До 30.09.2026 блок назывался «Что делать на этой неделе»,
    # а все полосы в нём считали всю волну с 03.09: читалось так, будто
    # за неделю вышло 28 статей. Теперь первая полоса - настоящая неделя
    # по сетке расписания (пн-вс), остальные - ход волны целиком.
    сегодня = date.today()
    пн = сегодня - timedelta(days=сегодня.weekday())
    вс = пн + timedelta(days=6)
    слоты_недели = [э for э in ряд
                    if (пн.month, пн.day) <= ключ_даты(э["дата"]) <= (вс.month, вс.day)]
    вышло_за_неделю = sum(1 for э in слоты_недели if "вышла" in э["пометка"])
    впереди = [э for э in слоты_недели if "вышла" not in э["пометка"]]
    неделя_полоса = next((п for п in план["полосы"] if п["название"] == "Эта неделя"), None)
    if неделя_полоса is None:
        неделя_полоса = {"название": "Эта неделя", "единица": "статей", "демо": False}
        план["полосы"].insert(0, неделя_полоса)
    неделя_полоса["факт"] = вышло_за_неделю
    неделя_полоса["план"] = len(слоты_недели)
    неделя_полоса["комментарий"] = (
        "неделя %s-%s%s"
        % (пн.strftime("%d.%m"), вс.strftime("%d.%m"),
           " · следующая: %s %s %s" % (впереди[0]["дата"], впереди[0].get("время", ""),
                                       впереди[0]["код"] or "тема не выбрана")
           if впереди else ""))

    аккаунтов = заведено_аккаунтов()
    for полоса in план["полосы"]:
        если_имя = полоса["название"]
        if если_имя == "Публикации по плану":
            полоса["факт"] = len(коды)
            полоса["план"] = len(очередь)
            полоса["комментарий"] = "вся волна, счёт по кластерам"
        elif если_имя == "Аккаунты на площадках" and аккаунтов is not None:
            всего_площадок = len(json.loads(ПЕРЕЧЕНЬ.read_text(encoding="utf-8"))["строки"])
            полоса["факт"] = аккаунтов
            полоса["план"] = всего_площадок
            полоса["комментарий"] = "аккаунт заведён или работа начата; счёт как на вкладке «Площадки»"
        elif если_имя in ("Брифы готовы", "Темы в работе"):
            # брифы отдельным этапом мы не ведём: тема из «Запланирована» уходит
            # сразу в текст. Полоса показывает то, что действительно движется.
            # Условие держим на оба названия: после переименования 14.09.2026
            # ветка перестала срабатывать, и полоса замерла на старых цифрах.
            в_работе = sum(1 for п in очередь if п["статус"] not in ("Опубликована", "Отложена"))
            осталось = sum(1 for п in очередь if п["статус"] != "Опубликована")
            полоса["название"] = "Темы в работе"
            полоса["факт"] = в_работе
            полоса["план"] = осталось
            полоса["единица"] = "темы"
            # «вместе с каталогом» писать нельзя: на паузе только витрина,
            # а статья А1 «Каталог франшиз» с 29.09.2026 снова в работе
            полоса["комментарий"] = ("из ненаписанных; %d отложены до весны 2027 вместе с витриной каталога"
                                     % (осталось - в_работе))
        elif если_имя == "Витрина каталога":
            # Витрина (страница с карточками) и статья А1 - разные вещи, и
            # подпись с одной датой паузы 03.09 читалась так, будто стоит и А1.
            # Дату слота А1 берём из расписания, словами её не вписываем.
            а1 = next((э for э in ряд if э["код"] == "А1"), None)
            if "А1" in вышли:
                про_а1 = "статья А1 уже вышла"
            elif а1:
                про_а1 = ("статья А1 выходит %s %s"
                          % (а1["дата"], а1.get("время", "")))
            else:
                про_а1 = "статья А1 в работе, слота нет"
            полоса["комментарий"] = "карточки франшиз отложены до весны 2027; %s" % про_а1.strip()

    план["календарь"] = календарь(ряд)

    ПЛАН.write_text(json.dumps(план, ensure_ascii=False, indent=1), encoding="utf-8")
    print("позиций в очереди:", len(очередь), "| опубликовано:", len(коды),
          "| аккаунтов:", аккаунтов)
    for с in сообщения:
        print("  ", с)


if __name__ == "__main__":
    главная()
