#!/usr/bin/env python3
"""Два разреза позиций для операторского экрана: от статьи и от ключа.

Устройство снято с эталона PR Factory (MyBiUSTY, /seo-positions, сверено
17.09.2026). Там экран оператора, а не клиента: заказчик видит только сводный
отчёт, поэтому здесь можно держать все состояния, включая пустые и неудобные.

Сверху разрез «от статьи»: пара «статья + ключ» с позицией, разложенная по
состояниям - стоим в выдаче, замер не удался, проверено и не нашли, не мерили
ни разу. Главное правило эталона, которое мы повторяем: «проверено и не нашли»
и «не мерили» - разные вещи, и валить их в один ноль нельзя.

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

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

import json
import sys
from pathlib import Path

ДАННЫЕ = Path(__file__).resolve().parent
КОРЕНЬ = ДАННЫЕ.parent.parent
ПОЗИЦИИ = ДАННЫЕ / "positions.json"
ЯДРО = ДАННЫЕ / "semantics.json"
РЕЕСТР = КОРЕНЬ / "завод" / "реестр-текстов.json"
ЗАПРОСЫ = КОРЕНЬ / "семантика" / "запросы-площадок.json"
ИСТОРИЯ = КОРЕНЬ / "семантика" / "позиции-площадок-история.json"
КЛЮЧИ = ДАННЫЕ / "keys.json"
РАСШИРЕНИЕ = КОРЕНЬ / "семантика" / "расширение.json"
ГЛУБИНА = 100


def имя_базы(площадка):
    return (площадка or "").split("(")[0].strip()


def пары_от_статьи(строки, реестр, запланированные):
    """Пара «страница + запрос» с её состоянием."""
    итог = []
    for с in строки:
        if с.get("демо"):
            continue
        адрес = с.get("страница") or ""
        если_адрес = adres_ok = bool(адрес) and адрес != "—"
        # Разрез «от статьи» держит только пары, у которых страница известна.
        # Выгрузка Вебмастера отдаёт запросы без страницы, и в этом разрезе
        # им не место: они целиком уходят в разрез «от ключа» (17.09.2026).
        if not если_адрес:
            continue
        состояние = ("сбой" if с.get("сбой")
                     else "в выдаче" if с.get("позиция")
                     else "не нашли")
        итог.append({
            "запрос": с.get("запрос"),
            "позиция": с.get("позиция"),
            "страница": адрес if если_адрес else None,
            "заголовок": реестр.get(адрес, {}).get("заголовок") or "",
            "площадка": с.get("площадка") or ("портал" if с.get("контур") == "SEO" else ""),
            "контур": с.get("контур"),
            "глубина": 100 if с.get("контур") == "PR" else None,
            "проверено": с.get("замер"),
            "состояние": состояние,
        })
    # Публикации, по которым замера не было вовсе: их нет в positions.json,
    # но они есть в списке запросов площадок. Ноль им ставить нельзя.
    известные = {(п["страница"], п["запрос"]) for п in итог if п["страница"]}
    for адрес, данные in запланированные.items():
        for запрос in данные["запросы"]:
            if (адрес, запрос) in известные:
                continue
            итог.append({
                "запрос": запрос, "позиция": None, "страница": адрес,
                "заголовок": реестр.get(адрес, {}).get("заголовок") or данные.get("имя") or "",
                "площадка": данные.get("площадка") or "", "контур": "PR",
                "глубина": None, "проверено": None, "состояние": "не мерили",
            })
    порядок = {"в выдаче": 0, "сбой": 1, "не нашли": 2, "не мерили": 3}
    итог.sort(key=lambda п: (порядок[п["состояние"]], п["позиция"] or 999))
    return итог


def разрез_по_ядру(ядро, строки):
    """Кластеры ядра: сколько ключей, по скольким стоим, лучшая позиция."""
    нашлись = {}
    for с in строки:
        if с.get("демо") or not с.get("позиция"):
            continue
        имя = (с.get("запрос") or "").strip().lower()
        если = нашлись.get(имя)
        if если is None or с["позиция"] < если:
            нашлись[имя] = с["позиция"]

    по_категориям, без_статьи = {}, []
    for к in ядро.get("строки", []):
        фразы = [(ф.get("фраза") or "").strip().lower() for ф in (к.get("фразы") or [])]
        свои = [нашлись[ф] for ф in фразы if ф in нашлись]
        имя = к.get("категория") or "Без категории"
        гр = по_категориям.setdefault(имя, {"категория": имя, "ключей": 0,
                                            "в_выдаче": 0, "лучшая": None})
        гр["ключей"] += len(фразы)
        гр["в_выдаче"] += len(свои)
        if свои:
            лучшая = min(свои)
            гр["лучшая"] = лучшая if гр["лучшая"] is None else min(гр["лучшая"], лучшая)
        есть_страница = (к.get("страница") or "не создана") != "не создана"
        if not есть_страница:
            без_статьи.append({"код": к.get("код"), "кластер": к.get("кластер"),
                               "категория": имя, "показы": к.get("показы"),
                               "волна": к.get("волна")})
    строки_ядра = sorted(по_категориям.values(), key=lambda г: -г["ключей"])
    без_статьи.sort(key=lambda к: -(к["показы"] or 0))
    return строки_ядра, без_статьи


def спрос_по_фразам():
    """Частотность фразы: сначала одобренные ключи ядра, потом расширение пула
    (Wordstat через XMLRiver). Нет ни там, ни там - спрос неизвестен, а не ноль."""
    спрос = {}
    if РАСШИРЕНИЕ.exists():
        р = json.loads(РАСШИРЕНИЕ.read_text(encoding="utf-8"))
        for кластер in (р.get("по_кластерам") or {}).values():
            for список in (кластер or {}).values():
                for з in список or []:
                    if isinstance(з, dict) and з.get("фраза") and з.get("показы") is not None:
                        спрос.setdefault(з["фраза"].lower(), з["показы"])
    if КЛЮЧИ.exists():
        for к in json.loads(КЛЮЧИ.read_text(encoding="utf-8")).get("строки", []):
            if к.get("фраза") and к.get("показы") is not None:
                спрос[к["фраза"].lower()] = к["показы"]
    return спрос


def прежние_позиции():
    """Прошлая позиция пары «страница + запрос» по датам замеров. Нужна для
    стрелки ▲▼ рядом с позицией, как на эталоне Adonis (12.10.2026)."""
    if not ИСТОРИЯ.exists():
        return {}
    по_парам = {}
    for снимок in sorted(json.loads(ИСТОРИЯ.read_text(encoding="utf-8")),
                         key=lambda с: с.get("замер") or ""):
        for с in снимок.get("строки") or []:
            if с.get("демо") or not с.get("страница") or с.get("сбой"):
                continue
            по_парам.setdefault((с["страница"], с.get("запрос")), []).append(
                (снимок.get("замер"), с.get("позиция")))
    return по_парам


def добавить_прежнюю_и_спрос(пары):
    история = прежние_позиции()
    спрос = спрос_по_фразам()
    for п in пары:
        п["частота"] = спрос.get((п["запрос"] or "").lower())
        п["прежняя"] = None
        if п["состояние"] != "в выдаче":
            continue
        # Берём последний замер раньше текущего: тот же день - это сам
        # текущий замер, попавший в историю.
        ранние = [поз for дата, поз in история.get((п["страница"], п["запрос"]), [])
                  if дата and п["проверено"] and дата < п["проверено"]]
        if ранние and ранние[-1]:
            п["прежняя"] = ранние[-1]


def топ_площадок(строки, реестр, запланированные):
    """ТОП ключей для PR-аналитики (по эталону PR Factory, 30.09.2026).

    Витрина для клиента: только запросы, по которым публикация на площадке уже
    стоит в первой сотне Яндекса. На запрос одна строка - лучшая публикация.
    Сдвиг считаем по запросу, а не по адресу: если позицию перехватила другая
    наша публикация, это всё равно движение по запросу."""
    свои = [с for с in строки if с.get("контур") == "PR" and not с.get("демо")]
    замер = max((с.get("замер") or "" for с in свои), default="")
    лучшие = {}
    for с in свои:
        if not с.get("позиция"):
            continue
        б = лучшие.get(с["запрос"])
        if б is None or с["позиция"] < б["позиция"]:
            лучшие[с["запрос"]] = с

    # Прошлый замер - последний снимок истории с датой раньше нынешнего.
    прошлое, прошлый_замер, мерили = {}, None, set()
    if ИСТОРИЯ.exists():
        снимки = [с for с in json.loads(ИСТОРИЯ.read_text(encoding="utf-8"))
                  if (с.get("замер") or "") < замер]
        if снимки:
            прошлый_замер = снимки[-1]["замер"]
            for с in снимки[-1].get("строки", []):
                if с.get("сбой"):
                    continue        # замер не состоялся - это не «не нашли»
                мерили.add(с["запрос"])
                if с.get("позиция"):
                    б = прошлое.get(с["запрос"])
                    прошлое[с["запрос"]] = с["позиция"] if б is None else min(б, с["позиция"])

    спрос = спрос_по_фразам()
    итог = []
    for запрос, с in лучшие.items():
        было = прошлое.get(запрос)
        адрес = с.get("страница") or ""
        заголовок = (реестр.get(адрес) or {}).get("заголовок") \
            or (запланированные.get(адрес) or {}).get("имя") or адрес
        итог.append({
            "запрос": запрос,
            "позиция": с["позиция"],
            "спрос": спрос.get(запрос.lower()),
            "сдвиг": (было - с["позиция"]) if было else None,
            "динамика": "сдвиг" if было else ("новое" if запрос in мерили else "первый замер"),
            "было": было,
            "страница": адрес,
            "заголовок": заголовок,
            "площадка": имя_базы(с.get("площадка")),
        })
    полоса = lambda п: 0 if п <= 10 else 1 if п <= 30 else 2
    итог.sort(key=lambda р: (полоса(р["позиция"]), -(р["спрос"] or -1), р["позиция"]))
    return {
        "строки": итог,
        "в_топ10": sum(1 for р in итог if р["позиция"] <= 10),
        "в_топ30": sum(1 for р in итог if 10 < р["позиция"] <= 30),
        "накрытый_спрос": sum(р["спрос"] or 0 for р in итог),
        "замер": замер or None,
        "прошлый_замер": прошлый_замер,
        "глубина": ГЛУБИНА,
    }


def главная():
    сухой = "--сухой" in sys.argv
    д = json.loads(ПОЗИЦИИ.read_text(encoding="utf-8"))
    ядро = json.loads(ЯДРО.read_text(encoding="utf-8")) if ЯДРО.exists() else {}

    реестр = {}
    if РЕЕСТР.exists():
        for т in json.loads(РЕЕСТР.read_text(encoding="utf-8"))["тексты"]:
            if т.get("ссылка"):
                реестр[т["ссылка"]] = {"заголовок": т.get("заголовок") or ""}

    запланированные = {}
    if ЗАПРОСЫ.exists():
        з = json.loads(ЗАПРОСЫ.read_text(encoding="utf-8"))
        список = з.get("публикации") if isinstance(з, dict) else з
        for п in (список or []):
            адрес = п.get("адрес") or п.get("ссылка")
            if адрес:
                запланированные[адрес] = {"запросы": п.get("запросы") or [],
                                          "площадка": имя_базы(п.get("площадка")),
                                          "имя": п.get("заголовок") or ""}

    пары = пары_от_статьи(д.get("строки", []), реестр, запланированные)
    добавить_прежнюю_и_спрос(пары)
    по_ядру, без_статьи = разрез_по_ядру(ядро, д.get("строки", []))

    состояния = {}
    for п in пары:
        состояния[п["состояние"]] = состояния.get(п["состояние"], 0) + 1
    страниц_всего = len({п["страница"] for п in пары if п["страница"]})
    страниц_в_выдаче = len({п["страница"] for п in пары
                            if п["страница"] and п["состояние"] == "в выдаче"})
    ключей_ядра = sum(г["ключей"] for г in по_ядру)
    ключей_в_выдаче = sum(г["в_выдаче"] for г in по_ядру)

    д["разрезы"] = {
        "топ_площадок": топ_площадок(д.get("строки", []), реестр, запланированные),
        "пары": пары,
        "по_ядру": по_ядру,
        "без_статьи": без_статьи,
        "итоги": {
            "страниц_всего": страниц_всего,
            "страниц_в_выдаче": страниц_в_выдаче,
            "пар_всего": len(пары),
            "в_топ10": sum(1 for п in пары if п["позиция"] and п["позиция"] <= 10),
            "ключей_ядра": ключей_ядра,
            "ключей_в_выдаче": ключей_в_выдаче,
            "кластеров_без_статьи": len(без_статьи),
            "состояния": состояния,
            "последний_замер": max((п["проверено"] for п in пары if п["проверено"]),
                                   default=None),
        },
    }
    if сухой:
        print("сухой прогон:", json.dumps(д["разрезы"]["итоги"], ensure_ascii=False))
        return 0
    ПОЗИЦИИ.write_text(json.dumps(д, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
    и = д["разрезы"]["итоги"]
    print(f"пар «статья + ключ»: {и['пар_всего']} ({и['состояния']}), "
          f"страниц в выдаче {и['страниц_в_выдаче']} из {и['страниц_всего']}, "
          f"ключей ядра в выдаче {и['ключей_в_выдаче']} из {и['ключей_ядра']}, "
          f"кластеров без статьи {и['кластеров_без_статьи']}")
    return 0


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