"""Трендвотчинг референс-каналов через TGStat API.

Лёгкий режим: channels/get + channels/posts + channels/subscribers
(~3 запроса на канал), без пер-постовой статистики (posts/stat не трогаем —
бережём месячный лимит). Ничего не пишет в channels.db — stateless.

Дайджест: прирост подписчиков за окно, топ-10 постов по охватам
(множитель от медианы своего канала) и топ-10 по репостам.

Медиана просмотров считается по постам возрастом 2–30 дней, чтобы свежие
посты с недобранными просмотрами её не занижали.

Запуск:
  venv/bin/python watch.py                # окно 4 дня
  venv/bin/python watch.py --days 7 --top 10

Список каналов: watch_channels.txt рядом со скриптом.
Дайджест пишется в волт: projects/channel/trendwatching/.
Последняя строка вывода: DIGEST=<путь> — для обвязки.
"""
from __future__ import annotations

import argparse
import html as html_module
import json
import os
import re
import statistics
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path

from dotenv import load_dotenv

from tgstat import NotFoundError, TGStatClient, TGStatError

SCRIPT_DIR = Path(__file__).resolve().parent
ENV_FILE = SCRIPT_DIR / ".env"
CHANNELS_FILE = SCRIPT_DIR / "watch_channels.txt"
def _state_file(label: str = "") -> Path:
    """Папка состояния: на Mac — Application Support, на Pi — из env TRENDWATCH_DATA_DIR."""
    base = os.environ.get("TRENDWATCH_DATA_DIR") or str(
        Path.home() / "Library" / "Application Support" / "tgstat-puller")
    suffix = f"-{label}" if label else ""
    return Path(base) / f"trendwatch-state{suffix}.json"

POSTS_PER_PAGE = 50
MAX_PAGES = 2               # до ~100 постов на канал — хватает и на окно, и на медиану
MEDIAN_MIN_AGE_H = 48       # посты моложе — не участвуют в медиане (просмотры не добраны)
MEDIAN_MAX_AGE_D = 30       # посты старше — не участвуют (другая эпоха канала)
FRESH_FLAG_H = 24           # посты моложе — помечаем «просмотры ещё растут»
MIN_TEXT_LEN = 30           # короче — почти всегда голые медиа
MENTIONS_TOP_N = 3          # для скольких самых растущих каналов тянуть упоминания
MENTIONS_MIN_DELTA = 20     # прирост меньше — упоминания не тянем (шум)
MAX_STAT_CALLS = 120        # потолок posts/stat за прогон (реальные репосты; 1 запрос = 1 пост)
MENTIONS_MIN_SUBS = 300     # упоминания от каналов мельче — мусор агрегаторов, не показываем
TEXT_PREVIEW_LEN = 300      # в дайджест идёт превью текста, не весь пост
TOP_PER_CHANNEL_CAP = 3     # не больше стольких постов одного канала в каждом топе

# --- сигнальный режим (--signals): только аномалии, для короткого мид-недельного пинга
SIGNAL_NORM = 2.0           # пост с охватом ×2+ от медианы канала — сигнал
SIGNAL_SHARE_TOTAL = 50     # суммарные пересылки+репосты — сигнал
SIGNAL_SHARE_RATE = 25      # шеримость на 1000 просмотров (при share_total >= 15) — сигнал
SIGNAL_GROWTH_PCT = 2.0     # прирост канала за окно, % — сигнал
SIGNAL_GROWTH_ABS = 300     # или абсолютный прирост — сигнал
MAX_STAT_CALLS_SIGNALS = 40 # потолок posts/stat в сигнальном режиме
SIGNAL_GROWTH_COOLDOWN_D = 3  # канал, давший сигнал роста, молчит столько дней
STATE_TTL_D = 30            # записи в памяти сигналов старше — чистим


def _as_obj(resp: object) -> dict:
    """Ответ TGStat как объект: на пустой выдаче API отдаёт `response: []`.

    26.07.2026 из-за этого падал весь биз-трек: у растущего канала не нашлось
    упоминаний за окно, `_request` вернул список, и `resp.get(...)` ронял прогон.
    Пустой список = пустой ответ, а не ошибка.
    """
    return resp if isinstance(resp, dict) else {}


def _clean_text(raw: str) -> str:
    """TGStat отдаёт текст с HTML-разметкой — чистим до плоского текста."""
    s = re.sub(r"<br\s*/?>", "\n", raw)
    s = re.sub(r"<[^>]+>", "", s)
    s = html_module.unescape(s)
    s = re.sub(r"[ \t]+", " ", s)
    return re.sub(r"\n{3,}", "\n\n", s).strip()


def _load_channels(channels_file: Path = CHANNELS_FILE) -> list[str]:
    if not channels_file.exists():
        sys.exit(f"Нет файла с каналами: {channels_file}")
    channels = []
    for line in channels_file.read_text(encoding="utf-8").splitlines():
        name = line.split("#", 1)[0].strip().lstrip("@")
        if name:
            channels.append(name)
    if not channels:
        sys.exit(f"Список каналов пуст: {channels_file}")
    return channels


def _collect_channel(client: TGStatClient, channel: str, cutoff: datetime,
                     now: datetime) -> dict:
    """Один канал: инфо + посты + динамика подписчиков за окно."""
    info = client.channels_get(f"@{channel}")
    title = info.get("title") or channel
    subscribers = info.get("participants_count") or 0

    # прирост подписчиков за окно
    subs_delta = None
    try:
        series = client.channels_subscribers(
            f"@{channel}",
            start_date=int(cutoff.timestamp()),
            end_date=int(now.timestamp()),
            group="day",
        )
        counts = [p.get("participants_count") for p in series
                  if isinstance(p, dict) and p.get("participants_count") is not None]
        if len(counts) >= 2:
            subs_delta = counts[0] - counts[-1]  # серия идёт от свежего к старому
    except TGStatError:
        pass  # прирост — не критично, канал всё равно в дайджесте

    posts = []
    offset = 0
    for _ in range(MAX_PAGES):
        resp = _as_obj(client.channels_posts(f"@{channel}", limit=POSTS_PER_PAGE, offset=offset))
        items = resp.get("items", [])
        if not items:
            break
        for it in items:
            ts = it.get("date")
            if not isinstance(ts, int):
                continue
            dt = datetime.fromtimestamp(ts, tz=timezone.utc)
            text = _clean_text(it.get("text") or "")
            link = it.get("link") or ""
            if link and not link.startswith("http"):
                link = f"https://{link}"
            posts.append({
                "dt": dt,
                "text": text,
                "link": link,
                "views": it.get("views") or 0,
                # channels/posts репосты не отдаёт — обогащаем через posts/stat ниже
                "shares": 0,      # пересылки людьми (sharesCount)
                "reposts": 0,     # репосты каналами (forwardsCount)
                "has_stat": False,
                "is_forward": bool(it.get("forwarded_from")),
            })
        offset += POSTS_PER_PAGE
        oldest = min(p["dt"] for p in posts)
        if oldest < cutoff - timedelta(days=MEDIAN_MAX_AGE_D):
            break

    return {"channel": channel, "title": title, "subscribers": subscribers,
            "subs_delta": subs_delta, "posts": posts}


def _collect_mentions(client: TGStatClient, collected: list[dict],
                      cutoff: datetime, now: datetime) -> None:
    """Для топ-растущих каналов тянет, кто их упоминал за окно (источник роста)."""
    growing = sorted(
        [c for c in collected if (c["subs_delta"] or 0) >= MENTIONS_MIN_DELTA],
        key=lambda c: -c["subs_delta"],
    )[:MENTIONS_TOP_N]
    for ch in growing:
        try:
            resp = _as_obj(client.channels_mentions(
                f"@{ch['channel']}",
                start_time=int(cutoff.timestamp()),
                end_time=int(now.timestamp()),
                limit=20,
            ))
        except TGStatError as e:
            print(f"    [!] mentions @{ch['channel']}: {e}")
            continue
        id2info = {}
        for m_ch in resp.get("channels") or []:
            cid = m_ch.get("id") or m_ch.get("channel_id")
            if cid is not None:
                id2info[cid] = {
                    "name": m_ch.get("username") or m_ch.get("title") or "?",
                    "subs": m_ch.get("participants_count") or 0,
                }
        mentions, tiny = [], 0
        for it in resp.get("items") or []:
            info = id2info.get(it.get("channelId"), {"name": "?", "subs": 0})
            if info["subs"] < MENTIONS_MIN_SUBS:
                tiny += 1  # мелкие агрегаторы — считаем, но не показываем
                continue
            mentions.append({
                "by": info["name"],
                "by_subs": info["subs"],
                "link": it.get("postLink") or "",
                "date": datetime.fromtimestamp(it["postDate"], tz=timezone.utc)
                        if isinstance(it.get("postDate"), int) else None,
            })
        ch["mentions"] = mentions
        ch["mentions_tiny"] = tiny
        print(f"[*] mentions @{ch['channel']}: {len(mentions)} (+{tiny} от мелких, скрыты)")


def _enrich_stats(client: TGStatClient, window_posts: list[dict],
                  cap: int = MAX_STAT_CALLS) -> None:
    """Реальные репосты/пересылки через posts/stat (1 запрос = 1 пост, с потолком).

    Если постов в окне больше потолка — обогащаем в порядке убывания множителя
    охвата (шеримость коррелирует с охватом, хвост теряем осознанно).
    """
    candidates = sorted(window_posts, key=lambda p: (p["norm"], p["views"]), reverse=True)
    capped = candidates[:cap]
    if len(candidates) > len(capped):
        print(f"[*] posts/stat: постов в окне {len(candidates)}, потолок {cap} — "
              f"хвост без репостов")
    done = 0
    for p in capped:
        try:
            stat = _as_obj(client.posts_stat(p["link"]))
        except NotFoundError:
            continue
        except TGStatError as e:
            print(f"    [!] posts/stat {p['link']}: {e}")
            break  # лимит/сеть — дальше не долбим
        p["shares"] = stat.get("sharesCount") or 0
        p["reposts"] = stat.get("forwardsCount") or 0
        p["has_stat"] = True
        done += 1
    print(f"[*] posts/stat: обогащено {done} постов")


def _median_views(posts: list[dict], now: datetime) -> float:
    lo = now - timedelta(days=MEDIAN_MAX_AGE_D)
    hi = now - timedelta(hours=MEDIAN_MIN_AGE_H)
    vals = [p["views"] for p in posts if lo <= p["dt"] <= hi and p["views"] > 0]
    return statistics.median(vals) if vals else 0.0


def _post_block(i: int, p: dict, metric_line: str) -> list[str]:
    ch = p["_ch"]
    flags = []
    if p["fresh"]:
        flags.append("🕐 свежий")
    if p["is_forward"]:
        flags.append("форвард")
    flag_str = f" | {', '.join(flags)}" if flags else ""
    preview = p["text"][:TEXT_PREVIEW_LEN].replace("\n", " ").strip()
    if len(p["text"]) > TEXT_PREVIEW_LEN:
        preview += "…"
    return [
        f"### {i}. {ch['title']} (@{ch['channel']})",
        f"- {p['dt'].astimezone().strftime('%Y-%m-%d %H:%M')} | {metric_line}{flag_str}",
        f"- {p['link']}",
        "",
        preview,
        "",
    ]


def prepare_window(collected: list[dict], days: int, now: datetime) -> list[dict]:
    """Отбирает посты окна и считает множители охвата. Возвращает плоский список."""
    cutoff = now - timedelta(days=days)
    flat = []
    for ch in collected:
        med = _median_views(ch["posts"], now)
        ch["median"] = med
        for p in ch["posts"]:
            if p["dt"] < cutoff or len(p["text"]) < MIN_TEXT_LEN:
                continue
            p["norm"] = round(p["views"] / med, 2) if med else 0.0
            p["fresh"] = (now - p["dt"]) < timedelta(hours=FRESH_FLAG_H)
            p["_ch"] = ch
            flat.append(p)
    return flat


def _cap_top(ranked: list[dict], top_n: int) -> list[dict]:
    """Топ с потолком постов на канал — частопостящие не забивают весь список."""
    counts: dict[str, int] = {}
    out = []
    for p in ranked:
        ch = p["_ch"]["channel"]
        if counts.get(ch, 0) >= TOP_PER_CHANNEL_CAP:
            continue
        counts[ch] = counts.get(ch, 0) + 1
        out.append(p)
        if len(out) >= top_n:
            break
    return out


def _compute_shares(flat: list[dict]) -> None:
    for p in flat:
        p["share_total"] = p["shares"] + p["reposts"]
        p["share_rate"] = round(p["share_total"] / p["views"] * 1000, 1) if p["views"] else 0.0


def _load_state(now: datetime, label: str = "") -> dict:
    """Память сигналов: что уже отправляли (против повторов при ежедневном окне)."""
    try:
        state = json.loads(_state_file(label).read_text(encoding="utf-8"))
    except (OSError, ValueError):
        state = {}
    posts = state.get("posts") or {}
    growth = state.get("growth") or {}
    floor = (now - timedelta(days=STATE_TTL_D)).strftime("%Y-%m-%d")
    return {
        "posts": {k: v for k, v in posts.items() if v >= floor},
        "growth": {k: v for k, v in growth.items() if v >= floor},
    }


def _pending_state_file(label: str = "") -> Path:
    """Отложенная память сигналов: trendwatch.sh промоутит её в боевую только
    после успешной отправки. Иначе сбой разбора съедает день сигналов молча —
    посты уже помечены показанными, а Наташа их не увидела (01.08.2026)."""
    path = _state_file(label)
    return path.parent / (path.name + ".pending")


def _save_state(state: dict, label: str = "", defer: bool = False) -> None:
    path = _pending_state_file(label) if defer else _state_file(label)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")


def build_signals_digest(collected: list[dict], flat: list[dict], days: int,
                         now: datetime, label: str = "",
                         defer_state: bool = False) -> tuple[str, bool]:
    """Короткий дайджест аномалий для ежедневного пинга. Возвращает (текст, есть_ли_сигналы)."""
    cutoff = now - timedelta(days=days)
    date_from = cutoff.astimezone().strftime("%d.%m.%Y")
    date_to = now.astimezone().strftime("%d.%m.%Y")
    today = now.astimezone().strftime("%Y-%m-%d")
    _compute_shares(flat)
    state = _load_state(now, label)

    reach_hits = [p for p in flat if p["norm"] >= SIGNAL_NORM
                  and p["link"] not in state["posts"]]
    share_hits = [p for p in flat
                  if (p["share_total"] >= SIGNAL_SHARE_TOTAL
                      or (p["share_rate"] >= SIGNAL_SHARE_RATE and p["share_total"] >= 15))
                  and p["link"] not in state["posts"]
                  and p not in reach_hits]
    growth_floor = (now - timedelta(days=SIGNAL_GROWTH_COOLDOWN_D)).strftime("%Y-%m-%d")
    growth_hits = []
    for ch in collected:
        d = ch["subs_delta"]
        if d is None or d <= 0:
            continue
        if state["growth"].get(ch["channel"], "") >= growth_floor:
            continue  # недавно уже сигналили про этот канал
        base = ch["subscribers"] - d
        pct = d / base * 100 if base > 0 else 0
        if d >= SIGNAL_GROWTH_ABS or pct >= SIGNAL_GROWTH_PCT:
            growth_hits.append((ch, d, pct))

    # запоминаем, о чём сигналим
    for p in reach_hits + share_hits:
        state["posts"][p["link"]] = today
    for ch, _, _ in growth_hits:
        state["growth"][ch["channel"]] = today
    _save_state(state, label, defer=defer_state)

    lines = [
        f"# Сигналы трендвотчинга — окно {date_from} – {date_to} ({days} дн.)",
        f"Собрано {now.astimezone().strftime('%Y-%m-%d %H:%M')}. "
        f"Постов в окне: {len(flat)}.",
        "",
    ]
    if not reach_hits and not share_hits and not growth_hits:
        lines.append("Сигналов нет: ни новых постов с охватом ×2+ от медианы, ни аномальной "
                     "шеримости, ни резкого роста каналов. Полная стата — в воскресенье.")
        return "\n".join(lines), False

    if reach_hits:
        lines += ["## Выбросы по охвату (×2+ от медианы канала)", ""]
        for i, p in enumerate(sorted(reach_hits, key=lambda p: -p["norm"]), 1):
            lines += _post_block(
                i, p, f"×{p['norm']} от медианы | 👁 {p['views']:,} "
                      f"| пересылки {p['shares']} + репосты {p['reposts']}")
    if share_hits:
        lines += ["## Аномально шеримое", ""]
        for i, p in enumerate(sorted(share_hits, key=lambda p: -p["share_total"]), 1):
            lines += _post_block(
                i, p, f"пересылки {p['shares']} + репосты каналов {p['reposts']} "
                      f"({p['share_rate']} на 1000 👁) | 👁 {p['views']:,}")
    if growth_hits:
        lines += ["## Резкий прирост каналов", ""]
        for ch, d, pct in sorted(growth_hits, key=lambda t: -t[1]):
            lines.append(f"- **{ch['title']}** (@{ch['channel']}): {d:+,} ({pct:+.1f}%)")
            for m in (ch.get("mentions") or [])[:5]:
                dd = m["date"].astimezone().strftime("%d.%m") if m["date"] else "?"
                by = m["by"] if str(m["by"]).startswith("@") else f"@{m['by']}"
                lines.append(f"  - упоминание {dd} {by} ({m['by_subs']:,} подп.): {m['link']}")
        lines.append("")

    return "\n".join(lines), True


def build_digest(collected: list[dict], flat: list[dict], days: int, top_n: int,
                 now: datetime) -> str:
    cutoff = now - timedelta(days=days)
    date_from = cutoff.astimezone().strftime("%d.%m.%Y")
    date_to = now.astimezone().strftime("%d.%m.%Y")

    _compute_shares(flat)

    top_reach = _cap_top(sorted(flat, key=lambda p: (p["norm"], p["views"]), reverse=True),
                         top_n)
    top_shared = _cap_top(sorted([p for p in flat if p["share_total"] > 0],
                                 key=lambda p: (p["share_total"], p["share_rate"]),
                                 reverse=True), top_n)

    lines = [
        f"# Трендвотчинг референс-каналов — окно {date_from} – {date_to} ({days} дн.)",
        f"Собрано {now.astimezone().strftime('%Y-%m-%d %H:%M')}. "
        f"Каналов: {len(collected)}. Постов в окне: {len(flat)}.",
        "",
        "Нормировка охватов: ×N — во сколько раз просмотры поста выше медианы своего",
        "канала (медиана — по постам возрастом 2–30 дней). Посты 🕐 моложе суток:",
        "просмотры ещё растут, множитель занижен.",
        "",
        "## Прирост подписчиков за окно",
        "",
    ]
    with_delta = [c for c in collected if c["subs_delta"] is not None]
    for ch in sorted(with_delta, key=lambda c: -(c["subs_delta"] or 0)):
        d = ch["subs_delta"]
        base = ch["subscribers"] - d
        pct = f" ({d / base * 100:+.1f}%)" if base > 0 and d else ""
        lines.append(f"- {ch['title']} (@{ch['channel']}): {d:+,}{pct} → {ch['subscribers']:,}")
    no_delta = [c for c in collected if c["subs_delta"] is None]
    if no_delta:
        lines.append(f"- нет данных по приросту: {', '.join('@' + c['channel'] for c in no_delta)}")

    mentioned = [c for c in collected if c.get("mentions") is not None]
    if mentioned:
        lines += ["", "## Кто упоминал растущие каналы (источник роста)", ""]
        for ch in mentioned:
            tiny_note = (f" (+{ch['mentions_tiny']} от каналов <{MENTIONS_MIN_SUBS} "
                         f"подписчиков, скрыты)" if ch.get("mentions_tiny") else "")
            if not ch["mentions"]:
                lines.append(f"**{ch['title']}** (@{ch['channel']}, {ch['subs_delta']:+,}): "
                             f"заметных упоминаний за окно нет{tiny_note} — рост органический "
                             "(свои посты/эфиры) или из источников вне телеги.")
                lines.append("")
                continue
            lines.append(f"**{ch['title']}** (@{ch['channel']}, {ch['subs_delta']:+,}) — "
                         f"упоминали за окно{tiny_note}:")
            for m in ch["mentions"][:10]:
                d = m["date"].astimezone().strftime("%d.%m") if m["date"] else "?"
                by = m["by"] if str(m["by"]).startswith("@") else f"@{m['by']}"
                lines.append(f"- {d} {by} ({m['by_subs']:,} подп.): {m['link']}")
            lines.append("")

    lines += ["", f"## Топ-{len(top_reach)} по охватам (множитель от медианы канала)", ""]
    for i, p in enumerate(top_reach, 1):
        shares_str = (f"пересылки {p['shares']} + репосты каналов {p['reposts']}"
                      if p["has_stat"] else "репосты: нет данных")
        lines += _post_block(
            i, p, f"×{p['norm']} от медианы | 👁 {p['views']:,} | {shares_str}")

    lines += ["", f"## Топ-{len(top_shared)} по шеримости (пересылки + репосты каналами)", ""]
    if not top_shared:
        lines += ["(ни одного поста с репостами в окне — либо окно тихое, "
                  "либо potолок posts/stat срезал данные)", ""]
    for i, p in enumerate(top_shared, 1):
        lines += _post_block(
            i, p, f"пересылки {p['shares']} + репосты каналов {p['reposts']} "
                  f"({p['share_rate']} на 1000 👁) | 👁 {p['views']:,} | ×{p['norm']} от медианы")

    return "\n".join(lines)


def main() -> None:
    ap = argparse.ArgumentParser(description="Трендвотчинг референс-каналов через TGStat.")
    ap.add_argument("--days", type=int, default=4, help="Окно в днях (по умолчанию 4)")
    ap.add_argument("--top", type=int, default=10, help="Постов в каждом топе (по умолчанию 10)")
    ap.add_argument("--signals", action="store_true",
                    help="Сигнальный режим: только аномалии (охват ×2+, шеримость, рост)")
    ap.add_argument("--channels", type=Path, default=CHANNELS_FILE,
                    help="Файл со списком каналов (по умолчанию watch_channels.txt)")
    ap.add_argument("--label", default="",
                    help="Лейбл трека — в имя файла дайджеста и state (напр. 'биз')")
    ap.add_argument("--defer-state", action="store_true",
                    help="Память сигналов — в .pending; двигать её боевой должен вызывающий "
                         "после успешной отправки (иначе сбой разбора съедает день сигналов)")
    args = ap.parse_args()

    load_dotenv(ENV_FILE)
    token = os.environ.get("TGSTAT_TOKEN")
    if not token:
        sys.exit("В .env нет TGSTAT_TOKEN")
    vault_root = os.environ.get("TGSTAT_VAULT_ROOT")
    if not vault_root:
        sys.exit("В .env нет TGSTAT_VAULT_ROOT")

    channels = _load_channels(args.channels)
    now = datetime.now(timezone.utc)
    cutoff = now - timedelta(days=args.days)
    print(f"Каналов: {len(channels)} | окно: {args.days} дн. | срез: {cutoff:%Y-%m-%d}")

    client = TGStatClient(token)
    collected, failed = [], []
    for ch in channels:
        print(f"[*] @{ch}...")
        try:
            data = _collect_channel(client, ch, cutoff, now)
        except NotFoundError:
            print("    [!] канал не найден в TGStat")
            failed.append(ch)
            continue
        except TGStatError as e:
            print(f"    [!] ошибка: {e}")
            failed.append(ch)
            continue
        print(f"    {len(data['posts'])} постов забрано, прирост: {data['subs_delta']}")
        collected.append(data)

    if not collected:
        sys.exit("Ни один канал не собрался — дайджест не пишем")

    _collect_mentions(client, collected, cutoff, now)

    flat = prepare_window(collected, args.days, now)
    _enrich_stats(client, flat, cap=MAX_STAT_CALLS_SIGNALS if args.signals else MAX_STAT_CALLS)

    has_signals = True
    if args.signals:
        digest, has_signals = build_signals_digest(collected, flat, args.days, now, args.label,
                                                  defer_state=args.defer_state)
        kind = "сигналы"
    else:
        digest = build_digest(collected, flat, args.days, args.top, now)
        kind = "дайджест"
    if failed:
        digest += f"\n\n---\nНе собрались: {', '.join('@' + c for c in failed)}\n"

    if args.label:
        kind = f"{kind}-{args.label}"

    out_dir = Path(vault_root) / "projects" / "channel" / "trendwatching"
    out_dir.mkdir(parents=True, exist_ok=True)
    out_path = out_dir / f"{{channel}} {{source}} трендвотчинг {kind} – {now.astimezone():%Y-%m-%d}.md"
    out_path.write_text(digest, encoding="utf-8")

    print(f"\nГотово: {kind} {out_path}")
    print(f"HAS_SIGNALS={'1' if has_signals else '0'}")
    print(f"DIGEST={out_path}")
    if args.signals and args.defer_state:
        print(f"STATE_PENDING={_pending_state_file(args.label)}")


if __name__ == "__main__":
    main()
