#!/usr/bin/env python3
"""Подтянуть в таблицу «Статьи» панели то, что реально написано.

Панель показывает очередь контент-плана, а состояние текстов живёт в их
frontmatter. Разойтись эти две вещи могут за день: статья написана, а в панели
у неё по-прежнему «Запланирована». Скрипт закрывает разрыв в одну сторону -
из файлов в панель, и только по трём полям: статус, ссылка, файл.

Строки, которых в таблице нет (материалы для внешних площадок, статьи вне
волны), добавляются с номерами от 22. Демо-строки (номера от 101) не трогаются:
они нарисованные, их задача показывать, как таблица выглядит с данными.

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

import json
import re
from pathlib import Path

КОРЕНЬ = Path(__file__).resolve().parent.parent.parent
РЕЕСТР = КОРЕНЬ / "завод" / "реестр-текстов.json"
ТАБЛИЦА = КОРЕНЬ / "панель" / "данные" / "articles.json"
ВИТРИНА = "https://smm.prfabrika.ru/"
ПОРТАЛ = "https://frantlas.ru/statya/"

# frontmatter пишется по-человечески, панель живёт на своей легенде статусов.
# Порядок важен: срабатывает первое совпадение, поэтому частные формулировки
# стоят выше общих. Текст, отправленный в редакцию, это «На модерации», а не
# «Готова» и не «Черновик»: разница в том, ждём мы себя или чужого редактора.
СТАТУСЫ = (("опубликован", "Опубликовано"),
           # отказ редакции или премодерации. Без этого правила отклонённый текст
           # падал в общий хвост таблицы и показывался как «Черновик готов»:
           # 16.09.2026 так висели все четыре наших отказа, включая два в Т-Ж
           ("отклон", "Отклонено"),
           # самопубликация с премодерацией (Песочница Habr, премодерация Fishki)
           # это тоже «На модерации»: решение принимает чужой модератор, не мы
           ("на модерацию", "На модерации"),
           ("на модерации", "На модерации"),
           # премодерация Fishki и Пикабу: слово другое, смысл тот же -
           # решение принимает чужой модератор. Без этой строки пост
           # показывался «Черновик готов» уже после отправки
           ("премодерац", "На модерации"),
           ("отправлено в редакцию", "На модерации"),
           ("ждёт ответа редакции", "На модерации"),
           ("ждём ответа", "На модерации"),
           ("не публиковать", "Черновик готов"),
           ("регистрация", "Отложена"),
           ("отложен", "Ждёт решения"),
           ("ждёт ответа", "Готова"),
           ("к публикации", "Готова"))

# площадка у материала берётся из его шапки, а кластер там не указывают:
# тему площадочного текста привязываем к кластеру портала вручную, по смыслу
КЛАСТЕР_ПЛОЩАДКИ = {
    "cossa-01-kak-prodayut-franshizy.md": ("А25", "исследование"),
    "дзен-01-proverka-franchaizera.md": ("А13", "прогрев"),
    "tzh-01-chto-vidno-do-pokupki.md": ("А14", "прогрев"),
    "vc-01-poryadok-oshibok.md": ("Б9", "разбор"),
    "pikabu-01-proverka-prishla.md": ("Б29", "прогрев"),
    "smartlab-01-okupaemost-franshizy.md": ("А17", "разбор"),
    "smartlab-02-biznes-plan-ne-shoditsya.md": ("Б14", "разбор"),
    "spark-01-podderzhka-po-reestram.md": ("Б18", "инструкция"),
    "audit-it-01-dogovor-franshizy-uchet.md": ("А14", "разбор"),
    "lj-01-okupaemost-schital-sam.md": ("А17", "прогрев"),
}


# Расписание выхода берём из таблицы ближайших недель контент-плана: там у каждой
# темы стоит день и пометка («в кроне, 11:00», «писать»). Панель показывает это
# отдельной колонкой, чтобы по запланированным статьям было видно не только
# «когда-нибудь», но и конкретную дату.
ПЛАН_ВОЛНЫ = КОРЕНЬ / "семантика" / "контент-план-волна-1.md"
СТРОКА_ПЛАНА = re.compile(
    r"^\|\s*(\d{2})\.(\d{2})\s+\S+\s*\|\s*\*\*([А-ЯЁA-Z]\d+)\*\*\s*([^|]*)\|"
    r"\s*([^|]*)\|[^|]*\|\s*([^|]*)\|",
    re.M)
ЯДРО = КОРЕНЬ / "семантика" / "ядро.json"
СЧЁТЧИК = КОРЕНЬ / "сайт" / "данные" / "просмотры.json"


def расписание_выхода(год):
    """{кластер: {дата, пометка, тема, контур}} из таблицы ближайших недель плана."""
    если_нет = {}
    if not ПЛАН_ВОЛНЫ.exists():
        return если_нет
    текст = ПЛАН_ВОЛНЫ.read_text(encoding="utf-8")
    для_кластера = {}
    for день, месяц, кластер, тема, контур, пометка in СТРОКА_ПЛАНА.findall(текст):
        для_кластера[кластер] = {
            "дата": f"{год}-{месяц}-{день}",
            "пометка": " ".join(пометка.split()),
            "тема": " ".join(тема.split()),
            "контур": " ".join(контур.split()),
        }
    # Сетка темпа 10 в неделю (30.09.2026) отменяет даты старых таблиц
    # и записана иначе: «| 02.10 пт 17:00 | бизнес | 2 | Б37 Бизнес-идеи |».
    # Её слоты перекрывают прежние: без этого у Б40 и А1 висели бы 09.10 и 14.10.
    for день, месяц, время, ветка_, код, тема in СТРОКА_СЕТКИ.findall(текст):
        для_кластера[код] = {
            "дата": f"{год}-{месяц}-{день}",
            "пометка": время,
            "тема": " ".join(тема.split()),
            "контур": " ".join(ветка_.split()),
        }
    return для_кластера


# слот сетки с выбранной темой; «П1 из ядра» и «А21 или А22» сюда не попадают
СТРОКА_СЕТКИ = re.compile(
    r"^\|\s*(\d{2})\.(\d{2})\s+\S+\s+(\d{1,2}:\d{2})\s*\|\s*([^|]*)\|[^|]*\|"
    r"\s*([АБМ]\d+)\s+(?!или\s)([^|(]*)", re.M)


def ключи_кластера(код):
    """Фразы кластера из ядра: у новой строки они те же, что у остальных тем."""
    if not ЯДРО.exists():
        return None, [], 0
    я = json.loads(ЯДРО.read_text(encoding="utf-8"))
    с = next((x for x in я.get("кластеры", []) if x.get("код") == код), None)
    if not с:
        return None, [], 0
    фразы = sorted(с.get("фразы") or [], key=lambda ф: -(ф.get("показы") or 0))
    # третьим числом - сколько ключей кластера отсеяно пальцем вниз (29.09.2026):
    # пустой список фраз при непустом отсеве это решение, а не пробел в замере
    return с.get("кластер"), [
        {"фраза": ф["фраза"], "показы": ф.get("показы"), "главный": и == 0}
        for и, ф in enumerate(фразы)], len(с.get("отсеяно") or [])


# «запланировано к публикации: вторник 15.09.2026, 11:00» - дата и время прямо
# в статусе файла. Это точнее плана: файл правит тот, кто ставит задание в крон.
ДАТА_В_СТАТУСЕ = re.compile(r"(\d{2})\.(\d{2})\.(\d{4})(?:\s*,\s*(\d{1,2}:\d{2}))?")


def просмотры_портала():
    """{слаг: просмотров} из своего счётчика статей портала.

    До 15.09.2026 эти числа лежали в панели мёртвым грузом: их вписали при
    первом заполнении и больше не трогали, а счётчик тем временем сперва
    считал, потом замолчал при переезде на WordPress. Теперь данные приходят
    из сайт/данные/просмотры.json, который наполняет сайт/просмотры-из-логов.py.
    """
    if not СЧЁТЧИК.exists():
        return {}
    д = json.loads(СЧЁТЧИК.read_text(encoding="utf-8"))
    return {слаг: (з or {}).get("всего") for слаг, з in (д.get("статьи") or {}).items()}


def образец(строки):
    """Пустая строка таблицы: берём поля существующей, чтобы колонки совпали."""
    пустая = {к: None for к in строки[0]}
    пустая.update({"рубрика": "Франшизы", "контур": "спутник · разбор", "приоритет": "P1",
                   "неделя": "-", "волна": "П1", "просмотры": None, "демо": False,
                   "ключи": [], "ключи_кластер": None, "ключи_замер": None,
                   "ключи_нота": "частотность не мерена"})
    return пустая


def главная():
    реестр = json.loads(РЕЕСТР.read_text(encoding="utf-8"))
    данные = json.loads(ТАБЛИЦА.read_text(encoding="utf-8"))
    строки = данные["строки"]
    живые = [с for с in строки if not с.get("демо")]
    счёт_портала = просмотры_портала()
    план = расписание_выхода((реестр["собрано"] or "2026")[:4])
    изменено, добавлено = [], []

    for текст in реестр["тексты"]:
        имя = Path(текст["файл"]).name
        низ = текст["статус"].lower()
        статус = next((з for сл, з in СТАТУСЫ if сл in низ), "Черновик готов")
        # ссылка в панели ведёт туда, где текст лежит для читателя: на площадку,
        # если он вышел, на портал, если опубликован там, и только для черновиков
        # на витрину. Раньше всё вело на витрину, и опубликованное выглядело
        # неопубликованным
        ссылка = (ВИТРИНА + текст["на_витрине"][0]) if текст["на_витрине"] else None
        if текст.get("ссылка"):
            ссылка = текст["ссылка"]
        elif "опубликовано на портале" in низ and текст.get("слаг"):
            ссылка = ПОРТАЛ + текст["слаг"] + "/"
        площадочный = текст["вид"] == "внешняя площадка"
        # Кластер берём из шапки текста: с 22.09.2026 он проставлен каждому
        # площадочному материалу, у которого тема совпадает с ядром. Ручной
        # список остаётся запасным - на случай, если шапку ещё не поправили.
        из_шапки = (текст.get("кластер") or "").strip()
        если_нет = "нет" in из_шапки.lower()
        кластер, тип = КЛАСТЕР_ПЛОЩАДКИ.get(имя, ("", "разбор"))
        if из_шапки and not если_нет:
            кластер = re.match(r"[А-ЯЁA-Z]\d+", из_шапки).group(0) if re.match(r"[А-ЯЁA-Z]\d+", из_шапки) else кластер
        elif если_нет:
            кластер = ""

        if площадочный:
            строка = next((с for с in живые if с.get("файл") == текст["файл"]), None)
        else:
            строка = next((с for с in живые if с.get("кластер") == текст["кластер"]
                           and с["площадка"].startswith("портал")), None)

        if строка is None:
            строка = образец(строки)
            строка.update({
                "№": max(с["№"] for с in строки) + 1,
                "заголовок": текст["заголовок"],
                "кластер": кластер if площадочный else текст["кластер"],
                "площадка": текст["площадка"] or "портал frantlas.ru",
                "тип": тип if площадочный else "разбор",
                "создана": текст["написано"] or реестр["собрано"],
            })
            строки.append(строка)
            живые.append(строка)
            добавлено.append(f'{строка["№"]} {строка["заголовок"][:40]} ({строка["площадка"]})')

        # Сколько иллюстраций у материала: в эталоне PR Factory под это отдельная
        # колонка «🖼» (сверено 14.09.2026). Обновляем на каждом прогоне, а не
        # только при создании строки: картинки часто добавляются после текста.
        строка["иллюстраций"] = len(текст.get("иллюстрации") or [])

        # Заголовок и площадку тоже подтягиваем каждый раз, а не только при
        # создании строки. 16.09.2026: в шапке текста площадку переименовали
        # «Audit-it.ru» -> «Audit-it», чтобы не считать одну площадку за две,
        # а панель осталась со старым именем и показывала 16 площадок вместо 15.
        # Раньше так же разошёлся заголовок у материала Pressfeed.
        if площадочный:
            if текст.get("заголовок"):
                строка["заголовок"] = текст["заголовок"]
            if текст.get("площадка"):
                строка["площадка"] = текст["площадка"]
            # Кластер тоже подтягиваем на каждом прогоне. 22.09.2026 кластеры
            # проставлены в шапках всех площадочных текстов, но существующие
            # строки обновлялись только по трём полям, и в панели у них
            # по-прежнему стояло пусто.
            строка["кластер"] = кластер

        # просмотры статьи портала из своего счётчика (по слагу файла)
        if not площадочный:
            слаг_ = текст.get("слаг") or Path(текст["файл"]).stem
            если_есть = счёт_портала.get(слаг_)
            if если_есть is not None:
                строка["просмотры"] = если_есть

        # Когда материал выйдет: сперва дата из статуса файла, иначе из плана
        # по кластеру. У опубликованных колонка пустая: им дата больше не нужна.
        строка["выход"], строка["выход_нота"] = None, None
        if статус not in ("Опубликовано", "Отклонена"):
            сырой_статус = текст.get("статус") or ""
            # Дату берём из статуса только тогда, когда она обещает выход.
            # Иначе сюда попадала дата отправки в редакцию («отправлено 04.09»),
            # и колонка «Выход» показывала прошедшее число.
            обещает = "запланировано" in сырой_статус.lower()
            в_статусе = ДАТА_В_СТАТУСЕ.search(сырой_статус) if обещает else None
            if в_статусе:
                д, м, г, время = в_статусе.groups()
                строка["выход"] = f"{г}-{м}-{д}"
                строка["выход_нота"] = время
            elif строка.get("кластер") in план:
                п = план[строка["кластер"]]
                строка["выход"], строка["выход_нота"] = п["дата"], п["пометка"]

        # Заголовок тоже обновляем: раньше он писался только при создании
        # строки, и переименование текста в панель не доходило. У Pressfeed
        # из-за этого месяц висело старое название «55 площадок для статей»,
        # хотя статья давно называлась иначе.
        строка["заголовок"] = текст["заголовок"]

        было = (строка.get("статус"), строка.get("ссылка"), строка.get("файл"))
        строка["статус"] = статус
        строка["ссылка"] = ссылка
        строка["файл"] = текст["файл"]
        if было != (статус, ссылка, текст["файл"]) and строка["№"] not in [int(д.split()[0]) for д in добавлено]:
            изменено.append(f'{строка["№"]} {строка["заголовок"][:40]}: {было[0]} → {статус}')

    # Тема стоит в расписании, а строки в таблице нет: 14.09.2026 так потерялись
    # Б22, Б28 и Б30 - в плане на 21-25.09 они были, а в панели не значились
    # вовсе. Заводим строку сами, чтобы расписание и таблица не расходились.
    # Ключи - ведомое поле: пересчитываем их каждому материалу с кластером.
    # Раньше они ставились только новой строке, и панель годами показывала
    # «частотность не мерена» там, где ядро давно перемерено (22.09.2026).
    # Ключи, которые материал площадки реально взял, стоят в его шапке
    # (поле «ключи», решение руководителя 30.09.2026). Раньше площадочной
    # строке подставлялись ВСЕ фразы кластера, и выходило, что один пост
    # Pikabu работает на двенадцать ключей разом.
    из_шапок = {т["файл"]: (т.get("ключи") or []) for т in реестр["тексты"]}
    частота = {}
    if ЯДРО.exists():
        я_ = json.loads(ЯДРО.read_text(encoding="utf-8"))
        for кл_ in я_.get("кластеры", []):
            for ф_ in (кл_.get("фразы") or []) + (кл_.get("пул") or []):
                имя_ = (ф_.get("фраза") or "").strip()
                if имя_ and имя_ not in частота:
                    частота[имя_] = ф_.get("показы")

    for с in строки:
        код = с.get("кластер")
        свои = из_шапок.get(с.get("файл") or "") or []
        # Площадочная строка с ключами в шапке: показываем именно их.
        if свои and str(с.get("площадка") or "") and not str(с.get("площадка") or "").startswith("портал"):
            с["ключи"] = [{"фраза": ф, "показы": частота.get(ф), "главный": и == 0}
                          for и, ф in enumerate(свои)]
            с["ключи_кластер"] = (ключи_кластера(код)[0] or с.get("ключи_кластер")) if код else с.get("ключи_кластер")
            с["ключи_замер"] = "из шапки материала"
            с["ключи_нота"] = ""
            continue
        if not код:
            # Материалы для площадок пишутся под жанр площадки, а не под ключ:
            # кластера у них нет, и это не пробел в замере, а другой контур.
            # С 30.09.2026 эта нота остаётся только для материалов с пустым
            # кластером: у остальных есть ключи в шапке, и они показаны выше.
            с["ключи"] = []
            с["ключи_нота"] = ("материал писался под жанр площадки, а не под "
                               "поисковый запрос: кластера у него нет")
            continue
        название, ключи, отсеяно = ключи_кластера(код)
        с["ключи"] = ключи
        с["ключи_кластер"] = название or с.get("ключи_кластер")
        с["ключи_замер"] = ("из ядра, перемерено 11.09.2026" if ключи
                            else "все ключи отбракованы" if отсеяно else "нет замера")
        с["ключи_нота"] = ("" if ключи
                           else f"все ключи кластера ({отсеяно}) отбракованы руководителем: палец вниз в панели"
                           if отсеяно else "кластер есть, фраз в ядре нет")

    контуры = {"бизнес": "Бизнес", "франшизы": "Франшизы"}
    известные = {с.get("кластер") for с in строки}
    for код, п in sorted(план.items(), key=lambda э: э[1]["дата"]):
        if код in известные:
            continue
        название, ключи, _ = ключи_кластера(код)
        строка = образец(строки)
        строка.update({
            "№": max(с["№"] for с in строки) + 1,
            "заголовок": название or п["тема"],
            "кластер": код,
            "рубрика": контуры.get(п["контур"].lower(), "Франшизы"),
            "площадка": "портал frantlas.ru",
            "тип": "разбор",
            "статус": "Запланирована",
            "создана": реестр["собрано"][:10],
            "ключи": ключи,
            "ключи_кластер": название or п["тема"],
            "ключи_замер": "замер 14.09.2026" if ключи else "нет замера",
            "ключи_нота": "" if ключи else "частотность не мерена",
            "выход": п["дата"],
            "выход_нота": п["пометка"],
        })
        строки.append(строка)
        добавлено.append(f'{строка["№"]} {строка["заголовок"][:40]} (из расписания плана)')

    # Темы, у которых ещё нет файла, через реестр не проходят: расписание
    # им проставляем отдельно, иначе у запланированных колонка пустая.
    for строка in строки:
        if строка.get("демо") or строка.get("статус") in ("Опубликовано", "Отклонена"):
            continue
        # у строки без файла дату ведёт только план, поэтому переписываем и
        # заполненную: после смены темпа 30.09.2026 у А1 осталось 14.10
        без_файла = not строка.get("файл") and строка.get("статус") == "Запланирована"
        if (без_файла or not строка.get("выход")) and строка.get("кластер") in план:
            п = план[строка["кластер"]]
            строка["выход"], строка["выход_нота"] = п["дата"], п["пометка"]

    строки.sort(key=lambda с: с["№"])
    данные["мета"]["источник"] = ("семантика/контент-план-волна-1.md · состояние текстов из "
                                 "завод/реестр-текстов.json (собирается из frontmatter файлов)")
    данные["мета"]["состояние_завода"] = (
        f'написано текстов: {реестр["итоги"]["всего"]} '
        f'({реестр["итоги"]["для_портала"]} для портала, '
        f'{реестр["итоги"]["для_внешних_площадок"]} для внешних площадок), опубликовано: '
        f'{реестр["итоги"]["опубликовано"]}')
    for п in ("Cossa", "Т-Ж Сообщество"):
        if п not in данные["мета"]["площадки"]:
            данные["мета"]["площадки"].append(п)

    ТАБЛИЦА.write_text(json.dumps(данные, ensure_ascii=False, indent=1), encoding="utf-8")
    print("обновлено строк:", len(изменено))
    for с in изменено:
        print("  ", с)
    print("добавлено строк:", len(добавлено))
    for с in добавлено:
        print("  ", с)


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