#!/usr/bin/env python3
"""Пересобрать PR-срез сводного отчёта из реестра текстов и базы площадок.

Зачем. Раздел «Сводный отчёт» показывал состояние внешнего контура руками:
цифры вписывались в report.json один раз и отставали на несколько дней.
07.09.2026 панель говорила «две публикации на внешних площадках», когда их
было семь, а Smart-Lab, Audit-it и LiveJournal в таблице отсутствовали вовсе.

Источники правды:
    завод/реестр-текстов.json - сколько материалов вышло и на какой площадке
    завод/площадки.json       - заведён ли аккаунт, что с ним не так, dofollow

Что скрипт трогает: у каждой площадки поля «опубликовано», «статус»,
«ограничение», «ссылки», плюс KPI и строку состояния. Демо-поля (всё,
что с суффиксом _демо) не трогает: они нарисованные и показывают, как
раздел выглядит с данными за четыре месяца.

Порядок: после завод/реестр-текстов.py, до обновить-фолбэк.py.

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

import json
import re
from collections import Counter
from pathlib import Path

КОРЕНЬ = Path(__file__).resolve().parent.parent.parent
РЕЕСТР = КОРЕНЬ / "завод" / "реестр-текстов.json"
ПЛОЩАДКИ = КОРЕНЬ / "завод" / "площадки.json"
ПРОСМОТРЫ_CSV = КОРЕНЬ / "завод" / "просмотры-публикаций.csv"
ОТЧЁТ = КОРЕНЬ / "панель" / "данные" / "report.json"


def _число(с):
    """Целое из ячейки таблицы: терпит пробелы, неразрывные пробелы, запятые."""
    return int(re.sub(r"[\s\u00a0,]", "", с))


def просмотры_публикаций():
    """Просмотры по публикациям из таблички `просмотры-публикаций.csv`.

    Одна строка = одна статья на площадке. Ключ - № статьи (как в панели).
    Возвращает {№: (просмотры, дата)}. Так вкладка «Статьи» получает число
    по каждой статье, а рейтинг площадок - сумму по площадке; многопубликационные
    площадки раскладываются корректно. Пустое поле - ещё не мерили. Числа площадки
    отдают только в своих кабинетах (API почти нет, Дзен - руками раз в неделю).
    """
    из_файла = {}
    if not ПРОСМОТРЫ_CSV.exists():
        return из_файла
    # файл заполняют руками, иногда через Excel: метка кодировки, кавычки,
    # запятая вместо точки с запятой. Разбор терпит это.
    for строка in ПРОСМОТРЫ_CSV.read_text(encoding="utf-8-sig").splitlines():
        строка = строка.strip()
        if not строка or строка.startswith("#"):
            continue
        разделитель = ";" if ";" in строка else ("\t" if "\t" in строка else ",")
        части = [ч.strip().strip('"').strip() for ч in строка.split(разделитель)]
        ключ = части[0]
        if not ключ.isdigit():
            continue  # шапка «№;…» или пустой ключ
        число = части[3] if len(части) > 3 else ""
        дата = части[4] if len(части) > 4 else ""
        if not число:
            # Числа нет. Если вместо даты стоит текст - это пометка о том,
            # что счётчика у площадки не существует («Работаем!» на Lemmy).
            # Она попадёт в клетку вместо прочерка: прочерк читается
            # как «забыли замерить», а тут мерить нечего.
            if дата and not дата[:1].isdigit():
                из_файла[int(ключ)] = (None, дата)
            continue
        try:
            из_файла[int(ключ)] = (_число(число), дата or None)
        except ValueError:
            print(f"  просмотры: не число у №{ключ}: {число!r}, пропускаю")
    return из_файла


# в реестре площадка пишется так, как её называет шапка материала,
# в базе - так, как она называет себя сама
СИНОНИМЫ = {"audit-it.ru": "Audit-it", "смарт-лаб": "Smart-Lab",
            "живой журнал": "LiveJournal", "вс.ру": "vc.ru"}

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


def имя_базы(площадка):
    """Имя, под которым площадка лежит в базе.

    В шапке материала её подписывают с уточнением - «Sostav (SBlogs)»,
    «Работаем! (rabotaem.app)», - а в базе имя короткое. Без отбрасывания
    скобок 14.09.2026 карточка не находилась, и отчёт писал, что тип ссылок
    у этих площадок не проверен, хотя у «Работаем!» стоит dofollow.
    """
    площадка = re.sub(r"\s*\([^)]*\)\s*$", "", str(площадка or "")).strip()
    return СИНОНИМЫ.get(площадка.lower(), площадка)


def короткий_статус(строка):
    """Первое слово статуса из базы: «работает», «заведено», «отложено»."""
    низ = строка.lower()
    if низ.startswith("работает") or "опубликован" in низ[:40]:
        return "работает"
    if "не заведено" in низ:
        return "не заведено"
    if "отложено" in низ:
        return "отложено"
    return "заведено"


def причина(площадка):
    """Одна строка о том, что мешает: её читает оператор, а не машина.

    Из статуса срезается только начальное слово-метка («работает - …»):
    оно уже стоит в своей колонке. Резать по первому дефису нельзя,
    в статусах живут адреса публикаций, и smart-lab.ru рвётся пополам.
    """
    статус = (площадка.get("статус") or "").strip()
    хвост = re.sub(r"^(работает|заведено|отложено|отправлено|отклонено)\s*[-\u2013\u2014:]\s*",
                   "", статус, flags=re.IGNORECASE)
    хвост = хвост.strip(" .")
    if len(хвост) <= 200:
        return хвост
    # режем по границе слова: иначе обрывается адрес публикации,
    # а он в этой колонке единственная кликабельная зацепка
    return хвост[:200].rsplit(" ", 1)[0] + "..."


def ссылки(площадка):
    """Передаёт ли площадка вес.

    Три состояния, а не два: «не проверено» это честный ответ, а поставить
    там nofollow значит соврать в отчёте. Метку ставим только после ручной
    проверки на своей публикации, и она записана в поле «ссылки_seo».
    """
    # Метка живёт в своём поле «ссылки_вес» и ставится руками после замера.
    # Разбирать её из свободного текста ненадёжно: «внешние ссылки без nofollow»
    # (Работаем!, вес передаётся) и «Без nofollow только соцсети площадки»
    # (Workspace, вес не передаётся) содержат одну и ту же подстроку
    # и означают противоположное
    метка = (площадка.get("ссылки_вес") or "").strip().lower()
    if метка in ("dofollow", "nofollow", "ссылок нет", "не проверено"):
        return метка
    return "не проверено"


def кратко_заголовок(т, предел=54):
    т = re.sub(r"\s+", " ", str(т or "")).strip()
    return т[:предел] + ("…" if len(т) > предел else "")


# Слова, по которым узнаём материал, ожидающий решения чужой редакции.
# «Отклонён» сюда не попадает: там ждать уже нечего.
ЖДЁМ = ("модерац", "отправлено в редакцию", "ждёт ответа", "ждём ответа",
        "лежит в ожидании")


def на_модерации():
    """Материалы, которые отправлены и ждут решения площадки.

    Руководитель 14.09.2026: в сводном отчёте видно только опубликованное,
    а тексты, лежащие у чужих редакций, не видны вовсе - хотя это тоже
    результат работы и по ним ждут ответа.
    """
    итог = []
    if not РЕЕСТР.exists():
        return итог
    р = json.loads(РЕЕСТР.read_text(encoding="utf-8"))
    for т in р["тексты"]:
        if т.get("вид") != "внешняя площадка":
            continue
        с = str(т.get("статус") or "").lower()
        if "отклон" in с or "опубликовано" in с:
            continue
        if any(сл in с for сл in ЖДЁМ):
            итог.append((имя_базы(т.get("площадка") or ""), т.get("заголовок") or "",
                         т.get("статус") or ""))
    return sorted(итог)


def главная():
    реестр = json.loads(РЕЕСТР.read_text(encoding="utf-8"))
    база = {п["имя"]: п for п in json.loads(ПЛОЩАДКИ.read_text(encoding="utf-8"))["площадки"]}
    отчёт = json.loads(ОТЧЁТ.read_text(encoding="utf-8"))
    pr = отчёт["pr"]

    # Просмотры теперь ведём по публикациям (№ статьи), а рейтинг площадок
    # собираем как сумму по площадке. Читаем статьи заранее: по ним же
    # № статьи связывается с её площадкой.
    статьи_путь = ОТЧЁТ.parent / "articles.json"
    статьи = json.loads(статьи_путь.read_text(encoding="utf-8")) if статьи_путь.exists() else {"строки": []}
    по_публикациям = просмотры_публикаций()          # {№: (просмотры, дата)}
    агрегат = {}                                       # {имя площадки: [сумма, дата]}
    for стр in статьи["строки"]:
        n = стр.get("№")
        if n not in по_публикациям:
            continue
        имя = имя_базы(стр.get("площадка") or "")
        в, дт = по_публикациям[n]
        if в is None:
            continue  # у площадки нет счётчика просмотров, суммировать нечего
        су = агрегат.setdefault(имя, [0, None])
        су[0] += в
        су[1] = max(су[1], дт) if (су[1] and дт) else (дт or су[1])

    # Регистр статуса не фиксирован: часть шапок пишет «опубликовано», часть
    # «ОПУБЛИКОВАНО» заглавными. Сравнение с учётом регистра молча теряло
    # TenChat и Workspace. Отдельно отсекаем случай, когда запись вышла,
    # но читателям не видна: считать её публикацией значит завышать отчёт
    def вышла(т):
        с = т["статус"].lower()
        return ("опубликовано" in с or "опубликован " in с) and "не видно читателям" not in с

    вышло = Counter(имя_базы(т["площадка"]) for т in реестр["тексты"]
                    if т["вид"] == "внешняя площадка" and вышла(т))

    # кого показываем: где есть публикации плюс где заведён аккаунт
    показываем = set(вышло)
    for имя, п in база.items():
        начало = (п.get("статус") or "").lower()
        if начало.startswith(НАЧАЛО_РАБОТЫ) and имя != "Портал frantlas.ru":
            показываем.add(имя)

    строки = {с["площадка"]: с for с in pr["площадки"]}
    образец = {к: (0 if isinstance(v, int) else None) for к, v in pr["площадки"][0].items()}
    добавлено, изменено = [], []

    for имя in sorted(показываем, key=lambda и: (-вышло[и], и)):
        п = база.get(имя, {})
        строка = строки.get(имя)
        if строка is None:
            строка = dict(образец)
            строка.update({к: v for к, v in pr["площадки"][0].items() if к.endswith("_демо")})
            строка.update({"площадка": имя, "демо": False, "просмотры": None,
                           "со_ссылкой": 0})
            pr["площадки"].append(строка)
            строки[имя] = строка
            добавлено.append(имя)

        было = (строка.get("опубликовано"), строка.get("статус"))
        строка["опубликовано"] = вышло[имя]
        строка["статус"] = короткий_статус(п.get("статус", "заведено"))
        строка["автопостинг"] = ("есть, публикуем скриптом" if п.get("автопостинг")
                                 else строка.get("автопостинг") or "нет API, только руками")
        строка["ограничение"] = причина(п) or строка.get("ограничение")
        строка["ссылки"] = ссылки(п)
        # Просмотры отражают ровно табличку: вписано - ставим, нет - прочерк.
        # Убрал(а) строку из CSV - число уходит из панели, а не залипает.
        _агр = агрегат.get(имя)
        строка["просмотры"], строка["замерено"] = (_агр[0], _агр[1]) if _агр else (None, None)
        if было != (строка["опубликовано"], строка["статус"]) and имя not in добавлено:
            изменено.append(f'{имя}: {было[0]} → {строка["опубликовано"]}, {было[1]} → {строка["статус"]}')

    # разведанные, но не заведённые площадки из отчёта убираем: они попадали
    # сюда при широком отборе и создавали ряд пустых строк
    лишние = [с["площадка"] for с in pr["площадки"]
              if not с.get("демо") and с["площадка"] not in показываем]
    pr["площадки"] = [с for с in pr["площадки"]
                      if с.get("демо") or с["площадка"] in показываем]
    pr["площадки"].sort(key=lambda с: (-(с.get("опубликовано") or 0), с["площадка"]))

    всего = sum(вышло.values())
    аккаунтов = len(показываем)
    вес = sorted(и for и in вышло if ссылки(база.get(и, {})) == "dofollow")
    ждут = на_модерации()
    для_kpi = {"статей опубликовано": (всего, ", ".join(f"{и}: {к}" for и, к in вышло.most_common())),
               "аккаунтов заведено": (аккаунтов, ", ".join(sorted(показываем))),
               "ждут ответа редакции": (
                   len(ждут),
                   "; ".join(f"{пл} - {кратко_заголовок(заг)}" for пл, заг, _ in ждут)
                   or "ничего не ждём")}
    метки = {k["метка"] for k in pr["kpi"]}
    if "ждут ответа редакции" not in метки:
        # показатель заведён 14.09.2026: до этого отправленное в редакции
        # нигде не отображалось. Ставим рядом с опубликованным.
        место = next((i for i, k in enumerate(pr["kpi"])
                      if k["метка"] == "статей опубликовано"), -1) + 1
        pr["kpi"].insert(место, {"метка": "ждут ответа редакции", "значение": 0,
                                 "демо": False, "комментарий": ""})
    for k in pr["kpi"]:
        if k["метка"] in для_kpi:
            k["значение"], k["комментарий"] = для_kpi[k["метка"]]

    # текст пустого рейтинга тоже с цифрой: стояло «две публикации есть»,
    # когда их было семь
    pr["рейтинг_пусто"] = (
        f"Рейтинг строится по просмотрам, а счётчиков на площадках у нас нет: "
        f"публикаций {всего}, но цифры по ним площадки отдают только в своём "
        f"интерфейсе. Порядок площадок в списке ниже задан числом публикаций, "
        f"а не результатом.")
    # про «остальных» пишем раздельно: закрытая ссылка и незамеренная это
    # разные вещи, и сваливать их в одну фразу значит выдавать догадку за замер
    закрыто = sorted(и for и in вышло if ссылки(база.get(и, {})) == "nofollow")
    без_замера = sorted(и for и in вышло if ссылки(база.get(и, {})) == "не проверено")
    хвост = []
    if вес:
        хвост.append(f"Вес порталу передаёт только {вес[0]}" if len(вес) == 1
                     else f"Вес порталу передают: {', '.join(вес)}")
    if закрыто:
        хвост.append(f"закрыты nofollow: {', '.join(закрыто)}")
    if без_замера:
        хвост.append(f"тип ссылки не проверен у: {', '.join(без_замера)}")
    ждёт_фраза = (f" Ещё {len(ждут)} материала ждут решения чужих редакций: "
                  + ", ".join(sorted({пл for пл, _, _ in ждут})) + "."
                  ) if ждут else ""
    pr["состояние"] = (f"публикаций на внешних площадках: {всего} на {len(вышло)} площадках. "
                       + ("; ".join(хвост) + "." if хвост else "Площадок с dofollow пока нет.")
                       + ждёт_фраза)
    ОТЧЁТ.write_text(json.dumps(отчёт, ensure_ascii=False, indent=1), encoding="utf-8")

    # Просмотры по публикациям - прямо в строки вкладки «Статьи»: у каждой
    # внешней статьи своё число из таблички. Внешние строки без числа сбрасываем
    # (не залипают), свой счётчик портала не трогаем.
    проставлено = 0
    for стр in статьи["строки"]:
        пл_raw = стр.get("площадка") or ""
        if "портал" in пл_raw.lower() or "frantlas" in пл_raw.lower():
            continue  # портал ведёт свой счётчик, он проставляется отдельно
        данные_публ = по_публикациям.get(стр.get("№"))
        стр.pop("просмотры_нота", None)
        стр.pop("отклик", None)          # прежняя колонка «Голоса / комм.», убрана 14.09.2026
        if данные_публ and данные_публ[0] is not None:
            стр["просмотры"] = данные_публ[0]
            проставлено += 1
        elif данные_публ:
            # числа нет и не будет: в клетке стоит пометка вместо прочерка
            стр["просмотры"] = None
            стр["просмотры_нота"] = данные_публ[1]
        else:
            стр["просмотры"] = None
    if статьи_путь.exists():
        статьи_путь.write_text(json.dumps(статьи, ensure_ascii=False, indent=1), encoding="utf-8")
        print(f"просмотры проставлены статьям: {проставлено}")

    print(f"публикаций: {всего}, площадок в отчёте: {аккаунтов}")
    for и in добавлено:
        print("  добавлена:", и)
    for и in лишние:
        print("  убрана:", и)
    for с in изменено:
        print("  ", с)


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