#!/usr/bin/env python3
"""Готовит ряды для графиков сводного отчёта.

Зачем отдельным скриптом. Панель ничего не считает: она рисует то, что лежит
в `панель/данные/*.json`. Графики - не исключение, иначе цифра на экране
зависела бы от того, в каком браузере её открыли.

Что собирает:
  seo.график_показов  - показы и клики по датам снимков Вебмастера;
  seo.график_запросов - те же снимки в разрезе запросов (столбики);
  seo.график_визитов  - визиты и просмотры по снимкам Метрики.

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

Запуск: python3 панель/данные/обновить-графики.py [--сухой]
"""

import json
import sys
from datetime import date, timedelta
from pathlib import Path

КОРЕНЬ = Path(__file__).resolve().parent.parent.parent
ОТЧЁТ = КОРЕНЬ / "панель" / "данные" / "report.json"
ВЕБМАСТЕР = КОРЕНЬ / "завод" / "вебмастер" / "история"
МЕТРИКА = КОРЕНЬ / "завод" / "метрика" / "история"
СНИМКОВ_В_СТОЛБИКАХ = 4      # как в образце: последние четыре замера, не все
МЕСЯЦЕВ_В_СТОЛБИКАХ = 4      # столько же, когда история перевалит за один месяц
ШАГ_СНИМКОВ_ДНЕЙ = 7         # окно Вебмастера - две недели, соседние дни почти совпадают
ЗАПРОСОВ_В_СТОЛБИКАХ = 12    # дальше подписи под осью перестают читаться
БАЗА_СДВИГА_ДНЕЙ = 7         # с каким отступом назад сравниваем позиции
ПОРОГ_СДВИГА = 0.5           # меньше - шум округления, а не движение

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


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


def по_русски_месяц(м):
    """2026-09 -> «сен 2026»."""
    год, месяц = м.split("-")
    return f"{МЕСЯЦЫ_РОД[int(месяц) - 1]} {год}"


def склонение(н, один, два, много):
    """«1 день», «2 дня», «5 дней» - подпись блока пишется числом из данных."""
    н = abs(int(н))
    if н % 10 == 1 and н % 100 != 11:
        return один
    if 2 <= н % 10 <= 4 and not 12 <= н % 100 <= 14:
        return два
    return много


def по_русски(дата):
    """2026-09-11 → «11 сен». Год не пишем: все снимки одного сезона."""
    год, месяц, день = дата.split("-")
    return f"{int(день)} {МЕСЯЦЫ[int(месяц) - 1][:3]}"


def снимки(папка):
    свои = []
    for файл in sorted(папка.glob("*.json")):
        try:
            свои.append((файл.stem, json.loads(файл.read_text(encoding="utf-8"))))
        except (ValueError, OSError):
            continue  # битый снимок пропускаем: график важнее одной точки
    return свои


def разреженные(свои, шаг, сколько):
    """Последние снимки не подряд, а через `шаг` дней: от свежего к старым.

    Если истории меньше одного шага, отдаём два крайних снимка: это честнее
    одного столбика и честнее четырёх одинаковых.
    """
    отобранные, край = [], None
    for дата, д in reversed(свои):
        д_дата = date.fromisoformat(дата)
        if край is None or (край - д_дата).days >= шаг:
            отобранные.append((дата, д))
            край = д_дата
        if len(отобранные) == сколько:
            break
    if len(отобранные) < 2 and len(свои) >= 2:
        отобранные = [свои[-1], свои[0]]
    return list(reversed(отобранные))


def ряды_вебмастера():
    свои = снимки(ВЕБМАСТЕР)
    if not свои:
        return None, None

    подписи = [по_русски(дата) for дата, _ in свои]
    показы, клики = [], []
    for _, д in свои:
        строки = д.get("строки") or []
        показы.append(int(sum(с.get("показы") or 0 for с in строки)))
        клики.append(int(sum(с.get("клики") or 0 for с in строки)))

    период = (свои[-1][1].get("период") or {})
    линия = {
        "подписи": подписи,
        "ряды": [{"имя": "показы", "точки": показы},
                 {"имя": "клики", "точки": клики}],
        "подпись": ("Точка - сумма за две недели до дня замера, а не за день. "
                    "Соседние точки пересекаются, складывать их нельзя."),
        "окно": f"{период.get('от', '?')} - {период.get('до', '?')}",
    }

    # Столбики по запросам. В эталоне PR Factory (MyBiUSTY) столбик - это месяц:
    # «по месяцам (последние 4 из 9), столбик на месяц, а не линия». Сверено
    # 14.09.2026 по просьбе руководителя. Переходим на месяцы, как только их
    # станет больше одного: сейчас вся история умещается в сентябрь, и помесячный
    # график дал бы один столбик на запрос - смотреть было бы не на что.
    месяцы = sorted({дата[:7] for дата, _ in свои})
    по_месяцам = len(месяцы) > 1

    if по_месяцам:
        # берём последний снимок каждого месяца: показы Вебмастера идут
        # скользящим окном, и складывать снимки внутри месяца нельзя
        последние = {}
        for дата, д in свои:
            последние[дата[:7]] = д
        хвост = [(м, последние[м]) for м in месяцы[-МЕСЯЦЕВ_В_СТОЛБИКАХ:]]
        ряды = [по_русски_месяц(м) for м, _ in хвост]
    else:
        # Не четыре подряд идущих снимка, а через неделю. Причина: Вебмастер
        # отдаёт скользящее окно в две недели, и у снимков за 14 и 15 сентября
        # общего в окне тринадцать дней из четырнадцати. Столбики выходили
        # одинаковыми: 12 запросов по 4 столбика, из них 47 повторяли друг
        # друга. С недельным шагом различий уже видно (проверено 15.09.2026).
        хвост = разреженные(свои, ШАГ_СНИМКОВ_ДНЕЙ, СНИМКОВ_В_СТОЛБИКАХ)
        ряды = [по_русски(дата) for дата, _ in хвост]

    по_запросам = {}
    for и, (_, д) in enumerate(хвост):
        for с in (д.get("строки") or []):
            имя = с.get("запрос")
            if not имя:
                continue
            по_запросам.setdefault(имя, [0] * len(хвост))[и] = int(с.get("показы") or 0)
    # фразы без единого показа за период на график не выводим - как в эталоне
    непустые = {и: з for и, з in по_запросам.items() if max(з) > 0}
    лучшие = sorted(непустые.items(), key=lambda п: -max(п[1]))[:ЗАПРОСОВ_В_СТОЛБИКАХ]
    скрыто = len(по_запросам) - len(непустые)

    если_месяцы = (f"Столбик - месяц (последние {len(хвост)} из {len(месяцы)}), "
                   f"а не линия: {len(по_запросам)} линий одного цвета не читаются.")
    # Про переход на месяцы больше не пишем читателю: это заметка себе,
    # она живёт в комментарии у по_месяцам. Подпись должна объяснять картинку.
    окно = (f"Замеры через {ШАГ_СНИМКОВ_ДНЕЙ} дней: Вебмастер считает показы "
            "за две недели, у соседних дней они почти совпадают."
            if len(хвост) > 1 else "Столбик пока один: замеров меньше двух.")
    если_замеры = (f"Столбик - замер, а не линия: {len(по_запросам)} линий одного "
                   "цвета не читаются. " + окно)
    столбики = {
        "ряды": ряды,
        "группы": [{"имя": имя, "значения": значения} for имя, значения in лучшие],
        "подпись": (если_месяцы if по_месяцам else если_замеры)
                   + (f" Показаны все {len(лучшие)} запросов."
                      if len(лучшие) == len(по_запросам)
                      else f" Показаны {len(лучшие)} запросов из {len(по_запросам)} "
                           "с наибольшими показами.")
                   + (f" Фразы без единого показа за период не выводятся: их {скрыто}, "
                      "они есть в таблице ниже." if скрыто else ""),
        "всего_запросов": len(по_запросам),
        "шаг": "месяц" if по_месяцам else "замер",
    }
    return линия, столбики


# Границы полос полуоткрытые: (от, до]. Раньше обе были включающими, и запрос
# ровно на десятом месте попадал и в «топ-10», и в «топ-30», а ровно на
# тридцатом - и в «топ-30», и в «дальше». Заодно переименована средняя полоса:
# «топ-30» читалось так, будто в неё входит и первая десятка (15.09.2026).
ПОЛОСЫ = [("топ-10", 0, 10), ("с 11 по 30", 10, 30),
          ("дальше тридцатого", 30, 10 ** 6)]


def топ_ключей():
    """Где мы реально стоим в Яндексе - витрина из образца PR Factory.

    Порядок не по позиции, а полосами: топ-10, топ-30, дальше. Внутри полосы
    сверху идёт запрос с большим спросом. Причина ровно та, что названа
    в образце: первое место по запросу на два показа предъявлять нечем,
    а пятнадцатое по запросу на тридцать - уже результат. Поэтому показы
    стоят рядом с позицией, а доля показов из топ-10 считается отдельно:
    без неё блок читается как «мы в топе», хотя в топ попал хвост.
    """
    свои = снимки(ВЕБМАСТЕР)
    if not свои:
        return None
    дата, последний = свои[-1]
    строки = [с for с in (последний.get("строки") or []) if с.get("позиция")]
    if not строки:
        return None

    # Позиции прошлого замера - чтобы показать сдвиг, а не голое число.
    # База не «вчерашний снимок», а снимок недельной давности: окно Вебмастера
    # две недели, у соседних дней оно совпадает на тринадцать дней из
    # четырнадцати, и сдвиг между ними всегда нулевой (15.09.2026).
    было = {}
    ранние = [п for п in свои[:-1]
              if (date.fromisoformat(дата) - date.fromisoformat(п[0])).days
              >= БАЗА_СДВИГА_ДНЕЙ]
    if ранние:
        было = {с.get("запрос"): с.get("позиция")
                for с in (ранние[-1][1].get("строки") or []) if с.get("позиция")}

    полосы = []
    for имя, от, до in ПОЛОСЫ:
        свои_строки = sorted([с for с in строки if от < с["позиция"] <= до],
                             key=lambda с: -(с.get("показы") or 0))
        if not свои_строки:
            continue
        полосы.append({
            "имя": имя,
            "строки": [{
                "запрос": с["запрос"],
                "позиция": round(с["позиция"], 1),
                "показы": int(с.get("показы") or 0),
                "клики": int(с.get("клики") or 0),
                "сдвиг": (round(было[с["запрос"]] - с["позиция"], 1)
                          if с["запрос"] in было else None),
            } for с in свои_строки],
        })

    всего_показов = sum(int(с.get("показы") or 0) for с in строки)
    в_топ10 = sum(int(с.get("показы") or 0) for с in строки if с["позиция"] <= 10)
    доля = round(в_топ10 / всего_показов * 100) if всего_показов else 0
    return {
        "полосы": полосы,
        "снято": дата,
        "запросов": len(строки),
        "показов_всего": всего_показов,
        "доля_показов_в_топ10": доля,
        "подпись": ("Полосами: топ-10, места с 11 по 30, остальное. Внутри полосы "
                    "сверху запрос с большим спросом."),
        "оговорка": (f"{доля}% показов в топ-10 - это брендовые и хвостовые запросы. "
                     "Коммерческие стоят ниже тридцатого."),
    }


ЖУРНАЛ_ПЕРЕОБХОДА = КОРЕНЬ / "завод" / "вебмастер" / "переобход-журнал.json"


def квота_переобхода():
    """Состояние суточной квоты Вебмастера.

    Раньше бралось только из снимка Вебмастера, а снимок делается утром
    в 05:30. Всё, что отправлено днём, в панель не попадало: 15.09.2026
    отправили 17 страниц, а отчёт весь день показывал «осталось 150,
    отправлено 0». Поэтому поверх снимка кладём журнал отправок, который
    пишет сам скрипт переобхода.
    """
    свои = снимки(ВЕБМАСТЕР)
    if not свои:
        return None
    дата, д = свои[-1]
    квота, остаток = д.get("квота_переобхода"), д.get("квота_остаток")
    отправка = д.get("последняя_отправка")

    if ЖУРНАЛ_ПЕРЕОБХОДА.exists():
        журнал = json.loads(ЖУРНАЛ_ПЕРЕОБХОДА.read_text(encoding="utf-8"))
        if журнал:
            последняя = журнал[-1]
            сегодня = date.today().isoformat()
            за_сегодня = sum(з.get("страниц", 0) for з in журнал
                             if str(з.get("когда", "")).startswith(сегодня))
            когда = str(последняя.get("когда", ""))[:10]
            # Остаток из журнала кладём поверх снимка, только если отправка
            # была после снимка. Иначе восьмидневная запись перебивала свежие
            # данные: 23.09.2026 Вебмастер отдавал 150 из 150, а панель
            # показывала 133 и дату 15.09 (23.09.2026).
            свежее_снимка = когда >= str(дата)[:10]
            if свежее_снимка:
                квота = последняя.get("квота") or квота
                остаток = последняя.get("квота_остаток", остаток)
                дата = когда
            отправка = {"когда": последняя.get("когда"),
                        "страниц": последняя.get("страниц", 0),
                        "за_сегодня": за_сегодня,
                        "давно": not свежее_снимка}
    if квота is None and остаток is None:
        return None
    return {
        "квота": квота,
        "квота_остаток": остаток,
        "снято": дата,
        # Что и когда мы в последний раз отправили. Раньше панель показывала
        # только остаток квоты, и после отправки на экране ничего не менялось.
        "последняя_отправка": (
            f"{отправка['когда']}: {отправка['страниц']} "
            + ("страница" if отправка["страниц"] == 1 else "страниц")
            + (f", всего за день {отправка['за_сегодня']}"
               if отправка["за_сегодня"] != отправка["страниц"] else "")
            + (" - это последняя отправка, с тех пор ничего не слали"
               if отправка.get("давно") else "")
        ) if отправка else "отправок пока не было",
        "как_отправляем": ("Страницы уходят на переобход скриптом "
                           "завод/вебмастер/переобход.py: оператору вручную делать нечего. "
                           "Здесь видно суточную квоту, её остаток и когда мы отправляли "
                           "в последний раз - скрипт ведёт журнал отправок."),
    }


def ряды_метрики():
    свои = снимки(МЕТРИКА)
    if not свои:
        return None
    подписи = []
    ряды = {"визиты": [], "посетители": [], "просмотры": [],
            "глубина": [], "среднее время": [], "отказы": []}
    МЕТКИ = {"визиты": "визитов за неделю", "посетители": "посетителей за неделю",
             "просмотры": "просмотров за неделю", "глубина": "глубина просмотра",
             "среднее время": "среднее время на сайте", "отказы": "отказы"}
    for дата, д in свои:
        # в снимке Метрики лежит готовый набор KPI, ищем нужные по метке
        kpi = {к.get("метка"): к.get("значение") for к in (д.get("kpi") or [])}
        подписи.append(по_русски(дата))
        for имя, метка in МЕТКИ.items():
            ряды[имя].append(kpi.get(метка))
    if not any(ряды["визиты"]) and not any(ряды["просмотры"]):
        return None

    # В эталоне PR Factory это не один график, а четыре рядом: визиты,
    # посетители, время на сайте и глубина. Так каждая величина читается
    # в своём масштабе - визиты и просмотры на одной оси давили глубину
    # в ноль (сверено 17.09.2026).
    def ряд(имя):
        точки = [з for з in ряды[имя] if з is not None]
        return {"имя": имя, "точки": [з or 0 for з in ряды[имя]]} if точки else None

    наборы = []
    for заголовок, состав, нота in (
        ("Визиты и посетители", ("визиты", "посетители"), "сколько раз заходили и сколько это людей"),
        ("Просмотры страниц", ("просмотры",), "открытий страниц, со служебными заходами"),
        ("Глубина просмотра", ("глубина",), "страниц за один визит"),
        ("Среднее время на сайте", ("среднее время",), "секунд на визит"),
        ("Отказы", ("отказы",), "процент визитов короче пятнадцати секунд"),
    ):
        свои_ряды = [р for р in (ряд(и) for и in состав) if р]
        if свои_ряды:
            наборы.append({"заголовок": заголовок, "нота": нота,
                           "подписи": подписи, "ряды": свои_ряды})

    return {
        "подписи": подписи,
        "ряды": [{"имя": "визиты", "точки": [з or 0 for з in ряды["визиты"]]},
                 {"имя": "просмотры", "точки": [з or 0 for з in ряды["просмотры"]]}],
        "наборы": наборы,
        # Графики строятся по истории снимков, а её собирали до 23.09.2026,
        # когда выгрузка ещё не отделяла служебный трафик. Поэтому линии выше
        # карточек: карточки уже очищены, а история - нет. Разойдётся это само,
        # когда в истории накопятся снимки с новым полем.
        "подпись": ("Метрика отдаёт данные за последние семь дней на дату снимка. "
                    "Точка это не день, а скользящая неделя: соседние точки "
                    "пересекаются. На линиях служебные заходы ещё не вычтены - "
                    "история снималась до 23.09.2026, когда их не отделяли. "
                    "Карточки выше уже без них, поэтому линии идут выше."),
    }


def сдвиги_позиций():
    """Что сдвинулось в позициях с прошлого замера.

    Блок «Изменения» в панели раньше был нарисован только для демо, а живому
    прогону показывал табличку «ни одного замера позиций» - неправду: снимки
    Вебмастера копятся с 07.09.2026. Теперь сдвиги считаются здесь, а панель
    их только рисует.

    Что с чем сравниваем. Берём последний снимок и самый свежий из тех, что
    отстоят от него хотя бы на неделю; если истории меньше недели - самый
    первый. Пара - это запрос, а не «страница и запрос»: отчёт Вебмастера
    по запросам страницу не отдаёт, в таблице позиций у всех строк прочерк.

    Оговорка, без которой блок врёт. Позиция в Вебмастере - среднее за две
    недели до дня замера. Окна соседних снимков пересекаются, и запрос с
    двумя показами за окно будет месяцами показывать одно и то же число:
    двигаться там нечему. Поэтому «без изменений» считаем и показываем
    отдельно, а не выдаём за штиль в выдаче.
    """
    свои = [(дата, д) for дата, д in снимки(ВЕБМАСТЕР)
            if any(с.get("позиция") for с in (д.get("строки") or []))]
    if len(свои) < 2:
        return None

    поз = lambda д: {с["запрос"]: с for с in (д.get("строки") or [])
                     if с.get("запрос") and с.get("позиция")}
    дата, последний = свои[-1]
    порог = date.fromisoformat(дата) - timedelta(days=БАЗА_СДВИГА_ДНЕЙ)
    ранние = [п for п in свои[:-1] if date.fromisoformat(п[0]) <= порог]
    база_дата, база = (ранние[-1] if ранние else свои[0])
    было, стало = поз(база), поз(последний)

    дней = (date.fromisoformat(дата) - date.fromisoformat(база_дата)).days
    поднялись, просели, ровно = [], [], 0
    for запрос, с in стало.items():
        if запрос not in было:
            continue
        сдвиг = round(было[запрос]["позиция"] - с["позиция"], 1)
        строка = {
            "запрос": запрос,
            "было": round(было[запрос]["позиция"], 1),
            "стало": round(с["позиция"], 1),
            "сдвиг": сдвиг,
            "показы": int(с.get("показы") or 0),
            "контур": "SEO",
        }
        if сдвиг >= ПОРОГ_СДВИГА:
            поднялись.append(строка)
        elif сдвиг <= -ПОРОГ_СДВИГА:
            просели.append(строка)
        else:
            ровно += 1

    поднялись.sort(key=lambda р: -р["сдвиг"])
    просели.sort(key=lambda р: р["сдвиг"])
    новых = len([з for з in стало if з not in было])

    движения = len(поднялись) + len(просели)
    почему = (" Пустые колонки означают именно это, а не штиль в выдаче."
              if not движения else "")
    return {
        "заголовок": f"Изменения за {дней} {склонение(дней, 'день', 'дня', 'дней')}",
        "дата": дата,
        "база_дата": база_дата,
        "дней": дней,
        "поднялись": поднялись,
        "просели": просели,
        "без_изменений": ровно,
        "новых": новых,
        "пар_с_историей": len(поднялись) + len(просели) + ровно,
        "подпись": (f"Сравниваем {по_русски(дата)} с {по_русски(база_дата)}. Считаем "
                    f"запросы, которые были в обоих замерах; ещё {новых} появились "
                    "только сейчас, их сравнивать не с чем."),
        "оговорка": (f"Без изменений: {ровно} "
                     f"{склонение(ровно, 'запрос', 'запроса', 'запросов')}. Вебмастер "
                     "считает среднее за две недели - без новых показов число "
                     "не двигается." + почему),
    }


def сдвиг_индекса():
    """Сколько страниц в поиске и на сколько это изменилось со вчера.

    Воронка показывала голое число. 15.09.2026 оно упало с 10 до 1, и по
    карточке этого было не видно вовсе: цифра просто стала другой. Считаем
    сдвиг тут, чтобы падение нельзя было проглядеть.
    """
    свои = [(дата, д) for дата, д in снимки(ВЕБМАСТЕР)
            if д.get("страниц_в_поиске") is not None]
    if not свои:
        return None
    дата, последний = свои[-1]
    сейчас = последний["страниц_в_поиске"]
    было = свои[-2][1]["страниц_в_поиске"] if len(свои) > 1 else None
    сдвиг = None if было is None else сейчас - было
    if сдвиг is None:
        текст = "первый замер, сравнивать не с чем"
    elif сдвиг == 0:
        текст = f"столько же, сколько {по_русски(свои[-2][0])}"
    else:
        знак = "+" if сдвиг > 0 else "-"
        текст = (f"{знак}{abs(сдвиг)} к замеру {по_русски(свои[-2][0])} "
                 f"(было {было})")
    return {
        "в_поиске": сейчас,
        "индекс_было": было,
        "индекс_сдвиг": сдвиг,
        "индекс_сдвиг_текст": текст,
        "индекс_снято": дата,
    }


def главная():
    сухой = "--сухой" in sys.argv
    отчёт = json.loads(ОТЧЁТ.read_text(encoding="utf-8"))
    линия, столбики = ряды_вебмастера()
    визиты = ряды_метрики()

    итог = []
    if линия:
        отчёт["seo"]["график_показов"] = линия
        итог.append(f"показы и клики: {len(линия['подписи'])} замеров")
    if столбики:
        отчёт["seo"]["график_запросов"] = столбики
        итог.append(f"запросы: {len(столбики['группы'])} из {столбики['всего_запросов']}")
    if визиты:
        отчёт["seo"]["график_визитов"] = визиты
        итог.append(f"визиты: {len(визиты['подписи'])} замеров")
    топ = топ_ключей()
    if топ:
        отчёт["seo"]["топ_ключей"] = топ
        итог.append(f"топ ключей: {топ['запросов']} запросов, "
                    f"{топ['доля_показов_в_топ10']}% показов в топ-10")
    сдвиги = сдвиги_позиций()
    if сдвиги:
        отчёт["seo"]["изменения"] = сдвиги
        отчёт["seo"]["изменения_подпись"] = сдвиги["подпись"]
        итог.append(f"сдвиги позиций: +{len(сдвиги['поднялись'])} / "
                    f"-{len(сдвиги['просели'])} за {сдвиги['дней']} дн.")
    индекс = сдвиг_индекса()
    if индекс:
        отчёт["seo"].setdefault("индексация", {}).update(индекс)
        итог.append(f"индекс: {индекс['в_поиске']} страниц "
                    f"({индекс['индекс_сдвиг_текст']})")
    квота = квота_переобхода()
    if квота:
        отчёт["seo"].setdefault("переобход", {}).update(квота)
        итог.append(f"квота переобхода: {квота['квота_остаток']} из {квота['квота']}")

    if not итог:
        print("данных для графиков нет: история Вебмастера и Метрики пуста")
        return 1
    if сухой:
        print("сухой прогон:", "; ".join(итог))
        return 0
    ОТЧЁТ.write_text(json.dumps(отчёт, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print("обновлено:", "; ".join(итог))
    return 0


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