#!/usr/bin/env python3
"""Мост: свежий отчёт GEO-монитора -> секция geo панели (data/report.json).

GEO-монитор (`завод/geo/monitor.py`) кладёт результат прогона в
`завод/geo/отчёты/ГГГГ-ММ-ДД.json`. Панель читает `данные/report.json`,
секцию `geo` (kpi + платформы). Этот скрипт переносит цифры из последнего
отчёта в панель - и ничего не придумывает: если отчёта нет или все платформы
пропущены (нет ключей), панель остаётся в честном состоянии «замер не
проводился».

Запуск: `python3 обновить-geo.py` (после каждого прогона монитора).
Флаг `--отчёт ПУТЬ` - взять конкретный файл, а не последний.
"""
import argparse
import json
import pathlib
import sys

ПАПКА = pathlib.Path(__file__).resolve().parent           # панель/данные
КОРЕНЬ = ПАПКА.parent.parent                               # папка проекта (SEO-PR завод Франтлас)
ФАЙЛ_ОТЧЁТА = ПАПКА / "report.json"
ПАПКА_ОТЧЁТОВ = КОРЕНЬ / "завод" / "geo" / "отчёты"

# id платформы монитора -> имя строки в панели (report.json.geo.платформы[].платформа).
# Яндексовые ya-neuro и alisa сводятся в одну строку панели: у них один ключ
# Yandex Cloud, и включаются они вместе. В названии строки это сказано явно,
# иначе не сходится счёт: в заголовке «4 платформы из 6», а строк пять.
СВЯЗЬ = {
    "claude":     "Claude",
    "chatgpt":    "ChatGPT",
    "perplexity": "Perplexity",
    "gemini":     "Gemini / Google AI Overview",
    "gigachat":   "GigaChat (Сбер)",
    "ya-neuro":   "Алиса и Я.Нейро (две платформы)",
    "alisa":      "Алиса и Я.Нейро (две платформы)",
}


def последний_отчёт():
    if not ПАПКА_ОТЧЁТОВ.is_dir():
        return None
    файлы = sorted(ПАПКА_ОТЧЁТОВ.glob("*.json"))
    return файлы[-1] if файлы else None


def свод_платформы(п):
    """Сводка исходов одной проверенной платформы отчёта -> поля панели.

    Знаменатель - реально ответившие промпты (проверено минус ошибки), а не все:
    иначе платформа, где половина запросов упала (кончились кредиты, 404 на
    модель), выглядела бы как «0% цитируемости», хотя это не замер, а сбой.
    """
    проверено = п.get("промптов_проверено", 0) or 0
    ошибок = п.get("ошибок", 0) or 0
    ответили = max(проверено - ошибок, 0)
    ссылка = п.get("процитированы_со_ссылкой", 0) or 0
    return {
        "проверено_шт": ответили,
        "процитирован": ссылка,
        "упомянут": п.get("упомянуты_без_ссылки", 0) or 0,
        "только_конкурент": п.get("только_конкурент", 0) or 0,
        "только_каталог": п.get("известен_только_каталог", 0) or 0,
    }



ТИПЫ = {
    "category": "Что вообще есть на рынке",
    "solution": "Как решить задачу",
    "comparison": "Что лучше выбрать",
    "brand": "Про нас по имени",
}
ВЕТКИ = {"А": "Франшизы", "Б": "Бизнес", "М": "Мосты"}
ИСХОДЫ = ("процитированы_со_ссылкой", "упомянуты_без_ссылки",
          "только_конкурент", "известен_только_каталог", "никого_не_назвал")


def имена_кластеров(корень):
    """Код кластера -> человеческое имя из семантического ядра."""
    файл = корень / "панель" / "данные" / "semantics.json"
    if not файл.exists():
        return {}
    ядро = json.loads(файл.read_text(encoding="utf-8"))
    return {с.get("код"): с.get("кластер") for с in (ядро.get("строки") or [])
            if с.get("код")}


def статьи_по_кластерам():
    """{код кластера: адрес или пометка} - что у нас уже вышло по теме."""
    файл = ФАЙЛ_ОТЧЁТА.parent.parent.parent / "завод" / "реестр-текстов.json"
    if not файл.exists():
        return {}
    итог = {}
    for т in json.loads(файл.read_text(encoding="utf-8")).get("тексты", []):
        код = т.get("кластер")
        если = str(т.get("статус") or "").lower()
        if код and "опубликован" in если and "не видно" not in если:
            итог.setdefault(код, т.get("ссылка") or "опубликовано")
    return итог


def по_темам(отчёт, корень):
    """Где ассистенты называют нас, а где только конкурентов.

    Зачем. В срезе «Когортный анализ» у GEO честно пусто: у ответа нет
    возраста. Но разрез у замера есть - по тому, о чём спросили. Отчёт монитора
    хранит исход каждого из 46 вопросов, и до 15.09.2026 эта разбивка никуда
    не показывалась.

    Группируем по типу вопроса, а не по кластеру: кластеров сорок три, и
    таблица на сорок три строки по четыре ответа в каждой ничего не объясняет.
    Отдельно выносим темы, где конкурента называют чаще всего: это список
    того, что занято.
    """
    детали = []
    охват = []
    for имя_пл, п in (отчёт.get("платформы") or {}).items():
        if п.get("статус") == "проверена":
            д = п.get("детали") or []
            if д:
                охват.append(имя_пл)
            детали.extend(д)
    if not детали:
        return None

    имена = имена_кластеров(корень)

    def свод(строки):
        счёт = {и: 0 for и in ИСХОДЫ}
        for д in строки:
            if д.get("исход") in счёт:
                счёт[д["исход"]] += 1
        return счёт

    # Таблица собирается из одного прогона, а KPI над ней - из всех строк
    # панели. 22.09.2026 на экране рядом стояли «нашли только конкурента 54»
    # и таблица, где сумма той же колонки 11: это был прогон по одной
    # платформе. Подписываем охват, чтобы числа не спорили молча.
    группы = []
    for код, имя in ТИПЫ.items():
        свои = [д for д in детали if д.get("тип") == код]
        if not свои:
            continue
        с = свод(свои)
        группы.append({"тема": имя, "ответов": len(свои), **с})

    ветки = []
    for код, имя in ВЕТКИ.items():
        свои = [д for д in детали if д.get("ветка") == код]
        if свои:
            ветки.append({"тема": имя, "ответов": len(свои), **свод(свои)})

    по_кластеру = {}
    for д in детали:
        к = д.get("кластер") or "-"
        по_кластеру.setdefault(к, []).append(д)
    # Есть ли у нас материал по теме. Без этого блок звал писать статьи там,
    # где они давно написаны: из десяти «занятых» тем семь были закрыты
    # нашими публикациями, а подпись всё равно требовала материалов
    # (23.09.2026). Разница важная: материала нет - писать; материал есть,
    # а нас не называют - работать над тем, чтобы находили.
    наши_статьи = статьи_по_кластерам()
    занятые = []
    for код, свои in по_кластеру.items():
        с = свод(свои)
        if с["только_конкурент"]:
            занятые.append({
                "кластер": код,
                "имя": имена.get(код) or код,
                "ответов": len(свои),
                "только_конкурент": с["только_конкурент"],
                "нас_назвали": с["процитированы_со_ссылкой"] + с["упомянуты_без_ссылки"],
                "наш_материал": наши_статьи.get(код) or "",
            })
    занятые.sort(key=lambda з: (-з["только_конкурент"], з["кластер"]))

    всего = len(детали)
    молчат = свод(детали)["никого_не_назвал"]
    return {
        "по_типу": группы,
        "по_ветке": ветки,
        "занятые": занятые[:10],
        "ответов": всего,
        "платформы": охват,
        "подпись": ("Строка - тип вопроса, числа - ответы по исходам. "
                    + (f"Прогон {'.'.join(reversed((отчёт.get('собран') or '')[:10].split('-')))}"
                       if отчёт.get("собран") else "Последний прогон")
                    + (f", платформы: {', '.join(охват)}." if охват else ".")),
        "оговорка": (f"В {молчат} ответах из {всего} не назвали никого: "
                     "вопрос не про выбор."),
    }


def история_замеров(папка):
    """Свод по каждому прогону монитора: было-стало от замера к замеру.

    Зачем. В панели у контура ГЕО срезы «Обзор» и «Аналитика» показывали почти
    одно и то же: Аналитика добавляла снизу одну карточку про промпты, и больше
    ничем не отличалась (найдено 15.09.2026). Разницу должен делать не набор
    карточек, а вопрос: Обзор отвечает «где мы сейчас», Аналитика - «что
    изменилось с прошлого замера». Для второго нужна история, и вот она.
    """
    замеры = []
    for файл in sorted(папка.glob("*.json")):
        if файл.suffix != ".json" or "." in файл.stem:
            continue          # резервные копии вида 2026-09-14.json.до-...
        try:
            отчёт = json.loads(файл.read_text(encoding="utf-8"))
        except ValueError:
            continue
        свод = {"процитирован": 0, "упомянут": 0, "только_конкурент": 0,
                "только_каталог": 0, "проверено_шт": 0}
        платформ = 0
        набор_платформ = set()
        по_платформам = {}
        for имя_пл, п in (отчёт.get("платформы") or {}).items():
            if п.get("статус") != "проверена":
                continue
            ч = свод_платформы(п)
            if not ч["проверено_шт"]:
                continue
            платформ += 1
            набор_платформ.add(имя_пл)
            по_платформам[имя_пл] = ч
            for к in свод:
                свод[к] += ч[к]
        if not платформ:
            continue
        замеры.append({
            "дата": файл.stem,
            "платформ": платформ,
            "имена_платформ": sorted(набор_платформ),
            "ответов": свод["проверено_шт"],
            "процитирован": свод["процитирован"],
            "упомянут": свод["упомянут"],
            "только_конкурент": свод["только_конкурент"],
            "только_каталог": свод["только_каталог"],
            "процент": round(свод["процитирован"] / свод["проверено_шт"] * 100, 1)
                       if свод["проверено_шт"] else 0,
            "по_платформам": по_платформам,
        })
    return замеры


def сдвиг_замеров(замеры):
    """Разница двух последних прогонов - словами, а не голыми числами."""
    if len(замеры) < 2:
        return {"есть": False,
                "текст": "Замер пока один: сравнивать не с чем. "
                         "Второй прогон идёт по расписанию."}
    б, с = замеры[-2], замеры[-1]
    # Сравниваем только платформы, которые мерили оба раза. Прогоны бывают
    # частичными: 14.09 прошли четыре платформы, 17.09 одна GigaChat, и разность
    # «43 → 11» читалась как улучшение вчетверо, хотя это вчетверо меньшая
    # выборка (22.09.2026). Тот же приём, что в «росте и застое» по площадкам.
    общие = sorted(set(б.get("по_платформам") or {}) & set(с.get("по_платформам") or {}))
    if not общие:
        порусски = lambda д: ".".join(reversed(д.split("-")))
        return {"есть": False,
                "текст": (f"Сравнивать нечего: {порусски(с['дата'])} и "
                          f"{порусски(б['дата'])} прошли по разным платформам, "
                          "общих нет. Разность считали бы по разным выборкам.")}

    def свести(замер):
        итог = {"процитирован": 0, "упомянут": 0, "только_конкурент": 0,
                "только_каталог": 0, "проверено_шт": 0}
        for имя in общие:
            for к in итог:
                итог[к] += замер["по_платформам"][имя][к]
        return итог

    бс, сс = свести(б), свести(с)
    поля = [("процитирован", "процитировали со ссылкой"),
            ("упомянут", "упомянули без ссылки"),
            ("только_конкурент", "назвали конкурента вместо нас"),
            ("только_каталог", "знают только каталоги")]
    строки = [{"метка": имя, "было": бс[к], "стало": сс[к], "сдвиг": сс[к] - бс[к]}
              for к, имя in поля]
    двинулось = [р for р in строки if р["сдвиг"]]
    порусски = lambda д: ".".join(reversed(д.split("-")))
    частично = len(общие) < max(б["платформ"], с["платформ"])
    оговорка = ("" if not частично else
                f" Только платформы из обоих прогонов: {', '.join(общие)}.")
    текст = (f"Сравниваем {порусски(с['дата'])} с {порусски(б['дата'])}. "
             + ("Ничего не сдвинулось." if not двинулось else
                "Сдвинулось: " + ", ".join(
                    f"{р['метка']} {р['было']} → {р['стало']}" for р in двинулось) + ".")
             + оговорка)
    return {"есть": True, "было_дата": б["дата"], "стало_дата": с["дата"],
            "платформ_сравнили": len(общие), "платформы": общие,
            "ответов_было": бс["проверено_шт"], "ответов_стало": сс["проверено_шт"],
            "строки": строки, "текст": текст}


ИСХОД_СЛОВАМИ = {
    "процитированы_со_ссылкой": "сослались на нас",
    "упомянуты_без_ссылки": "назвали без ссылки",
    "только_конкурент": "только конкурент",
    "известен_только_каталог": "только каталог",
    "никого_не_назвал": "никого не назвал",
}


def промпты_по_платформам(папка):
    """{строка панели: разбор по каждому промпту} - для раскрывающихся списков.

    Платформы мерят в разные дни (GigaChat 30.09, остальные 28.09), поэтому
    разбор каждой берём из её последнего отчёта, а не из самого свежего файла:
    иначе после прогона одной платформы списки остальных пропали бы.
    Текст вопроса берём из prompts.json по id: в отчёте его нет (05.10.2026).
    """
    файл_промптов = КОРЕНЬ / "завод" / "geo" / "prompts.json"
    тексты, типы = {}, {}
    if файл_промптов.exists():
        набор = json.loads(файл_промптов.read_text(encoding="utf-8")).get("промпты", [])
        тексты = {п["id"]: п.get("текст", "") for п in набор}
        типы = {п["id"]: п.get("тип") for п in набор}
    последний = {}  # pid -> (дата, платформа отчёта)
    for файл in sorted(папка.glob("*.json")):
        отчёт = json.loads(файл.read_text(encoding="utf-8"))
        for pid, п in (отчёт.get("платформы") or {}).items():
            if pid in СВЯЗЬ and п.get("статус") == "проверена" and п.get("детали"):
                последний[pid] = (отчёт.get("дата"), п)
    итог = {}
    for pid, (дата, п) in последний.items():
        строка = СВЯЗЬ[pid]
        for д in п["детали"]:
            итог.setdefault(строка, []).append({
                "id": д.get("id"),
                "текст": тексты.get(д.get("id"), ""),
                # У упавшего запроса в отчёте нет ни типа, ни исхода: тип берём
                # из набора, исход помечаем «ошибка», иначе строка без счёта
                # всплывала в начало списка (05.10.2026).
                "тема": ТИПЫ.get(д.get("тип") or типы.get(д.get("id")), д.get("тип") or ""),
                "исход": "ошибка" if д.get("ошибка") else д.get("исход"),
                "ошибка": д.get("ошибка"),
                "исход_словами": ("ошибка запроса" if д.get("ошибка")
                                  else ИСХОД_СЛОВАМИ.get(д.get("исход"), д.get("исход") or "")),
                "конкуренты": [{"имя": к.get("имя"), "со_ссылкой": bool(к.get("со_ссылкой"))}
                               for к in д.get("конкуренты_в_ответе") or []],
                "начало_ответа": (д.get("начало_ответа") or "").strip()[:400],
                "площадка": п.get("название", pid).split(" —")[0],
                "дата": дата,
            })
    return итог


def платформы_монитора():
    """Описания платформ берём из самого монитора: один список на двоих,
    иначе он разъедется при добавлении новой платформы (17.09.2026)."""
    import importlib.util
    файл = ПОРТАЛ_GEO / "monitor.py" if "ПОРТАЛ_GEO" in globals() else None
    путь = файл or (pathlib.Path(__file__).resolve().parent.parent.parent
                    / "завод" / "geo" / "monitor.py")
    сп = importlib.util.spec_from_file_location("geo_monitor", путь)
    м = importlib.util.module_from_spec(сп)
    сп.loader.exec_module(м)
    return getattr(м, "ПЛАТФОРМЫ", [])


def main():
    р = argparse.ArgumentParser()
    р.add_argument("--отчёт", help="путь к конкретному отчёту монитора")
    а = р.parse_args()

    путь = pathlib.Path(а.отчёт) if а.отчёт else последний_отчёт()
    if not путь or not путь.exists():
        print("Отчётов GEO-монитора нет - панель оставляю как есть "
              "(честное «замер не проводился»).")
        return 0

    отчёт = json.loads(путь.read_text(encoding="utf-8"))
    данные = json.loads(ФАЙЛ_ОТЧЁТА.read_text(encoding="utf-8"))
    geo = данные["geo"]
    платформы_отчёта = отчёт.get("платформы", {})

    # Собираем по строкам панели: агрегируем все id монитора, ведущие в одну строку.
    по_строкам = {}
    for pid, п in платформы_отчёта.items():
        строка = СВЯЗЬ.get(pid)
        if not строка or п.get("статус") != "проверена":
            continue
        свод = свод_платформы(п)
        # Число ответивших промптов держим прямо в строке: без него знаменатель
        # цитируемости приходилось собирать из исходов, а «никого не назвал»
        # в строке нет - получалось 31 вместо 46 (17.09.2026).
        свод["ответов"] = свод["проверено_шт"]
        if свод["проверено_шт"] == 0:
            continue  # платформа «проверена», но ни один ответ не дошёл (сбой) - не замер
        по_строкам.setdefault(строка, []).append(свод)

    дата = отчёт.get("дата")
    собран = отчёт.get("собран") or дата

    # Обновляем строки платформ панели строго по проверенным.
    for стр in geo["платформы"]:
        имя = стр["платформа"]
        части = по_строкам.get(имя)
        if not части:
            continue  # платформа не проверена - оставляем прочерки и «не подключён»
        сумма = {k: sum(ч[k] for ч in части) for k in части[0]}
        проверено = сумма["проверено_шт"]
        стр["проверено"] = дата
        стр["процитирован"] = сумма["процитирован"]
        стр["упомянут"] = сумма["упомянут"]
        стр["только_конкурент"] = сумма["только_конкурент"]
        стр["только_каталог"] = сумма["только_каталог"]
        стр["ответов"] = проверено
        стр["процент"] = round(сумма["процитирован"] / проверено * 100) if проверено else None
        стр["ключ"] = стр["ключ"].replace("не подключён", "подключён")

    # Разбор по каждому промпту - под таблицей раскрывающимися списками.
    разбор = промпты_по_платформам(путь.parent)
    for стр in geo["платформы"]:
        стр["промпты"] = разбор.get(стр["платформа"], [])

    # Общий свод для KPI. Считаем по строкам панели, а не по одному отчёту:
    # платформы можно мерить по отдельности (17.09.2026 так подключали
    # GigaChat), и тогда свежий отчёт содержит только её. Взяв его целиком,
    # панель обнулила бы остальные четыре платформы.
    все = [{"проверено_шт": с.get("ответов") or 0,
            "процитирован": с.get("процитирован") or 0,
            "упомянут": с.get("упомянут") or 0,
            "только_конкурент": с.get("только_конкурент") or 0,
            "только_каталог": с.get("только_каталог") or 0}
           for с in geo["платформы"] if с.get("проверено")]
    if все:
        n = sum(ч["проверено_шт"] for ч in все)
        ссылка = sum(ч["процитирован"] for ч in все)
        упом = sum(ч["упомянут"] for ч in все)
        конк = sum(ч["только_конкурент"] for ч in все)
        кат = sum(ч["только_каталог"] for ч in все)
        значения = [
            round(ссылка / n * 100) if n else None,  # цитируемость, %
            ссылка,                                    # с реальной ссылкой
            упом,                                      # упоминают без ссылки
            конк,                                      # только конкурент
            кат,                                       # только каталог
        ]
        for kpi, зн in zip(geo["kpi"], значения):
            kpi["значение"] = зн
            kpi["демо"] = False
        geo["состояние"] = "замер проведён"
        geo["последняя_проверка"] = собран
        geo["почему_нельзя_запустить"] = ""
        geo["промтов_в_наборе"] = отчёт.get("промптов_в_наборе", geo.get("промтов_в_наборе"))
        замеры = история_замеров(путь.parent)
        geo["история"] = замеры
        geo["сдвиг"] = сдвиг_замеров(замеры)
        темы = по_темам(отчёт, ФАЙЛ_ОТЧЁТА.parent.parent.parent)
        if темы:
            geo["по_темам"] = темы

        # Заголовок и оговорка собираются из самого отчёта, а не пишутся руками.
        # 14.09.2026 руководитель попросила перепроверить вкладку, и нашлись три
        # расхождения: заголовок обещал «5 платформ, живой поиск», хотя платформ
        # в наборе шесть, замер прошёл на четырёх (у Алисы и Я.Нейро нет ключа
        # Yandex Cloud), а в вебе реально ищет только Perplexity - остальные
        # отвечают по знаниям модели. Для них «нас не назвали» означает
        # «модель про нас не знает», а не «нас нет в выдаче».
        # Считаем по строкам панели, а не по одному отчёту: иначе после
        # прогона одной платформы заголовок писал «1 платформы из 1»
        # (17.09.2026). «Алиса и Я.Нейро» одной строкой это две платформы -
        # счёт ведём по названию, там это сказано словами.
        def сколько(строка):
            return 2 if "две платформы" in (строка.get("платформа") or "") else 1
        всего_платформ = sum(сколько(с) for с in geo["платформы"])
        проверено_платформ = sum(сколько(с) for с in geo["платформы"] if с.get("проверено"))
        # Кто ищет в вебе: список собираем по всем платформам монитора,
        # а не по одному отчёту - иначе после частичного прогона фраза
        # «в вебе реально ищет только...» теряла Perplexity.
        с_поиском = [п.get("название", п["id"]).split(" (")[0]
                     for п in платформы_монитора() if п.get("поиск_в_вебе")]
        слово = ("платформа" if проверено_платформ % 10 == 1 and проверено_платформ % 100 != 11
                 else "платформы" if 2 <= проверено_платформ % 10 <= 4
                 and not 12 <= проверено_платформ % 100 <= 14 else "платформ")
        geo["заголовок"] = (f"ИИ-цитируемость бренда ({проверено_платформ} {слово} из "
                            f"{всего_платформ} в наборе)")
        # Счётчик «платформ замерено» в карточке «Набор промптов» брался из
        # одного отчёта и после прогона только GigaChat показывал «1 из 1»,
        # хотя замерено пять из семи (17.09.2026). Считаем так же, как заголовок.
        geo["платформ_всего"] = всего_платформ
        geo["платформ_проверено"] = проверено_платформ
        geo["пояснение"] = (
            "Раз в две недели спрашиваем ИИ-ассистентов про франшизы. Исходы: "
            "ссылка на нас, упоминание без ссылки, конкурент, только каталоги. "
            + (f"В вебе ищут только {', '.join(с_поиском)}, остальные отвечают "
               "по памяти модели." if с_поиском else ""))
        пропущены = [с["платформа"] for с in geo["платформы"] if not с.get("проверено")]
        geo["оговорка"] = (
            f"Первый прогон 08.09.2026: ноль по нам и снимок того, кого нейросети "
            f"называют вместо нас. Это точка отсчёта. На каждой платформе прогнали "
            f"{отчёт.get('промптов_в_наборе')} промптов."
            + (f" Не замерены: {', '.join(пропущены)} - нет ключа Yandex Cloud."
               if пропущены else ""))

    ФАЙЛ_ОТЧЁТА.write_text(
        json.dumps(данные, ensure_ascii=False, indent=1), encoding="utf-8"
    )
    проверено_строк = len(по_строкам)
    print(f"Панель обновлена из {путь.name}: платформ с замером {проверено_строк}, "
          f"промптов на платформу до {max((ч['проверено_шт'] for ч in все), default=0)}.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
