"""Мониторинг англоязычных источников: рассылки, блоги, подкасты.

База — скрипт из скилла Kumar & Solo (t.me/solokumi), адаптирован под пайплайн
трендвотчинга Наташи: дедуп-память (сигналим только про новое), дайджест в волт,
маркер HAS_NEW для обвязки. Без токенов и API-ключей, только стандартная
библиотека + dotenv (ради TGSTAT_VAULT_ROOT). Источники — не только AI-линия:
в sources.txt отдельной линией живёт Lenny's Newsletter (продукт, рост, карьера),
у неё свои критерии в промптах.

Запуск:
  venv/bin/python sources_watch.py                    # окно 2 дня, до 5 на источник
  venv/bin/python sources_watch.py --days 7 --per-source 7

Формат sources.txt (одна строка на источник, | — разделитель, # — комментарий):
  Название | rss     | url-фида
  Название | sitemap | url-sitemap.xml | фильтр-подстрока-в-ссылке
  Название | page    | url-страницы   | фильтр-подстрока-в-href

Строка с меньше чем тремя полями или с пустым полем среди первых трёх считается
битой: источник пропускается, а предупреждение уходит в stderr (см. _report_broken).

Первый прогон с пустой памятью только запоминает текущее (HAS_NEW=0),
чтобы не вывалить полсотни старых материалов разом.
"""
from __future__ import annotations

import argparse
import html
import json
import os
import re
import sys
import time
import urllib.request
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
from urllib.parse import urljoin

from dotenv import load_dotenv

SCRIPT_DIR = Path(__file__).resolve().parent
ENV_FILE = SCRIPT_DIR / ".env"
SOURCES_FILE = SCRIPT_DIR / "sources.txt"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
STATE_TTL_D = 45


def _state_file() -> Path:
    base = os.environ.get("TRENDWATCH_DATA_DIR") or str(
        Path.home() / "Library" / "Application Support" / "tgstat-puller")
    return Path(base) / "enwatch-state.json"


def fetch(url: str) -> str:
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=25) as resp:
        return resp.read().decode("utf-8", errors="replace")


def clean(raw: str, limit: int = 500) -> str:
    s = re.sub(r"<!\[CDATA\[(.*?)\]\]>", r"\1", raw, flags=re.DOTALL)
    s = re.sub(r"<[^>]+>", " ", s)
    s = html.unescape(s)
    s = re.sub(r"\s+", " ", s).strip()
    return s[:limit]


def parse_date(s: str) -> datetime | None:
    s = s.strip()
    try:
        return parsedate_to_datetime(s)
    except (ValueError, TypeError):
        pass
    try:
        dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
        return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
    except ValueError:
        return None


def _first(pattern: str, block: str) -> str:
    m = re.search(pattern, block, re.DOTALL)
    return m.group(1) if m else ""


def collect_rss(url: str, cutoff: datetime) -> list[dict]:
    xml = fetch(url)
    items = []
    for block in re.findall(r"<(?:item|entry)[\s>].*?</(?:item|entry)>", xml, re.DOTALL):
        title = clean(_first(r"<title[^>]*>(.*?)</title>", block), 200)
        link = _first(r'<link[^>]*rel="alternate"[^>]*href="([^"]+)"', block) \
            or _first(r'<link[^>]*href="([^"]+)"[^>]*/?>', block) \
            or clean(_first(r"<link[^>]*>(.*?)</link>", block), 300)
        date_raw = _first(r"<(?:pubDate|published|updated)[^>]*>(.*?)</", block)
        dt = parse_date(date_raw) if date_raw else None
        if dt is None or dt < cutoff:
            continue
        summary = clean(_first(
            r"<(?:description|summary|media:description)[^>]*>(.*?)"
            r"</(?:description|summary|media:description)>", block))
        items.append({"title": title or "(без названия)", "link": link.strip(),
                      "date": dt.strftime("%Y-%m-%d"), "_dt": dt, "summary": summary})
    items.sort(key=lambda x: x["_dt"], reverse=True)
    return items


def collect_sitemap(url: str, cutoff: datetime, pattern: str) -> list[dict]:
    xml = fetch(url)
    items = []
    for block in re.findall(r"<url>(.*?)</url>", xml, re.DOTALL):
        loc = _first(r"<loc>(.*?)</loc>", block).strip()
        if not loc or (pattern and pattern not in loc):
            continue
        dt = parse_date(_first(r"<lastmod>(.*?)</lastmod>", block) or "")
        if dt is None or dt < cutoff:
            continue
        slug = loc.rstrip("/").rsplit("/", 1)[-1]
        title = slug.replace("-", " ").capitalize()
        items.append({"title": title, "link": loc, "date": dt.strftime("%Y-%m-%d"),
                      "_dt": dt, "summary": ""})
    items.sort(key=lambda x: x["_dt"], reverse=True)
    return items


def collect_page(url: str, pattern: str, limit: int) -> list[dict]:
    page = fetch(url)
    seen, items = set(), []
    for href in re.findall(r'href="([^"]+)"', page):
        if pattern not in href or href.rstrip("/").endswith(pattern.strip("/")):
            continue
        if href.endswith((".xml", ".rss", ".json")):
            continue
        absolute = urljoin(url, href)
        if absolute in seen:
            continue
        seen.add(absolute)
        slug = absolute.rstrip("/").rsplit("/", 1)[-1]
        items.append({"title": slug.replace("-", " ").capitalize(), "link": absolute,
                      "date": "свежее (дат нет)", "_dt": None, "summary": ""})
        if len(items) >= limit:
            break
    return items


def _load_state(now: datetime) -> dict:
    try:
        state = json.loads(_state_file().read_text(encoding="utf-8"))
    except (OSError, ValueError):
        state = {}
    seen = state.get("seen") or {}
    floor = (now - timedelta(days=STATE_TTL_D)).strftime("%Y-%m-%d")
    return {"seen": {k: v for k, v in seen.items() if v >= floor}}


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


def _load_sources(path: Path) -> tuple[list[dict], list[str]]:
    """Разбирает sources.txt: возвращает (источники, битые строки).

    Битая строка не роняет прогон, но и не проглатывается молча — main её
    печатает: опечатка в разделителе иначе тихо убирает источник из
    мониторинга, и заметить это нечем.
    """
    sources: list[dict] = []
    broken: list[str] = []
    for raw in path.read_text(encoding="utf-8").splitlines():
        line = raw.split("#", 1)[0].strip()
        if not line:
            continue
        parts = [p.strip() for p in line.split("|")]
        if len(parts) < 3 or not all(parts[:3]):
            broken.append(raw.strip())
            continue
        sources.append({"name": parts[0], "type": parts[1].lower(), "url": parts[2],
                        "pattern": parts[3] if len(parts) > 3 else ""})
    return sources, broken


def _report_broken(broken: list[str]) -> None:
    """Печатает предупреждения о битых строках sources.txt в stderr.

    В stderr — потому что enwatch.sh/trendwatch.sh забирают stdout скрипта
    в переменную и в лог не пишут; только stderr уходит в лог через 2>>.
    """
    for bad in broken:
        print(f"[!] строка sources.txt не разобралась, источник пропущен: {bad}",
              file=sys.stderr)


def main() -> None:
    ap = argparse.ArgumentParser(
        description="Дайджест англоязычных источников: AI-линия + линия Lenny.")
    ap.add_argument("--days", type=int, default=2, help="Глубина в днях (по умолчанию 2)")
    ap.add_argument("--per-source", type=int, default=5, help="Максимум материалов на источник")
    ap.add_argument("--full", action="store_true",
                    help="Недельный дайджест: всё за окно, без дедупа (для воскресной сводки)")
    args = ap.parse_args()

    load_dotenv(ENV_FILE)
    vault_root = os.environ.get("TGSTAT_VAULT_ROOT")
    if not vault_root:
        sys.exit("В .env нет TGSTAT_VAULT_ROOT")
    if not SOURCES_FILE.exists():
        sys.exit(f"Нет файла с источниками: {SOURCES_FILE}")

    sources, broken = _load_sources(SOURCES_FILE)
    _report_broken(broken)
    if not sources:
        sys.exit("Список источников пуст — заполните sources.txt")

    now = datetime.now(timezone.utc)
    cutoff = now - timedelta(days=args.days)
    today = now.astimezone().strftime("%Y-%m-%d")
    state = _load_state(now)
    first_run = not state["seen"]
    print(f"Источников: {len(sources)} | окно: {args.days} дн. | в памяти: {len(state['seen'])}")

    results: list[tuple[dict, list[dict]]] = []
    for src in sources:
        print(f"[*] {src['name']} ({src['type']})...")
        try:
            if src["type"] == "rss":
                items = collect_rss(src["url"], cutoff)[:args.per_source]
            elif src["type"] == "sitemap":
                items = collect_sitemap(src["url"], cutoff, src["pattern"])[:args.per_source]
            elif src["type"] == "page":
                items = collect_page(src["url"], src["pattern"], args.per_source)
            else:
                continue
        except Exception as e:
            print(f"  [!] не собрался ({e})")
            items = []
        if args.full:
            fresh = items  # недельный дайджест — всё за окно, без дедупа
        else:
            fresh = [it for it in items if it["link"] not in state["seen"]]
        for it in items:
            state["seen"][it["link"]] = today
        print(f"    {len(items)} материалов, новых: {len(fresh)}")
        results.append((src, fresh))

    _save_state(state)

    total_new = sum(len(items) for _, items in results)
    if first_run and not args.full:
        print("\nПервый прогон: база запомнена, сигналов не даём.")
        print("HAS_NEW=0")
        return

    kind = "en-дайджест" if args.full else "en-сигналы"
    title = "EN-дайджест недели" if args.full else "EN-сигналы"
    lines = [
        f"# {title} — {now.astimezone().strftime('%Y-%m-%d %H:%M')} "
        f"(окно {args.days} дн.)",
        f"Материалов: {total_new}.",
        "",
        "У источников типа page дат нет — «свежее» значит «появилось в ленте с прошлого",
        "прогона». У ютуб/подкаст-выпусков в описании — саммари эпизода.",
        "",
    ]
    for src, items in results:
        if not items:
            continue
        lines.append(f"## {src['name']}")
        for it in items:
            lines.append(f"- **{it['title']}** — {it['date']}")
            lines.append(f"  {it['link']}")
            if it["summary"]:
                lines.append(f"  {it['summary']}")
        lines.append("")

    out_dir = Path(vault_root) / "projects" / "channel" / "trendwatching"
    out_dir.mkdir(parents=True, exist_ok=True)
    out_path = out_dir / f"{{channel}} {{source}} трендвотчинг {kind} – {today}.md"
    out_path.write_text("\n".join(lines), encoding="utf-8")

    print(f"\nГотово: новых {total_new}, файл: {out_path}")
    print(f"HAS_NEW={'1' if total_new else '0'}")
    print(f"DIGEST={out_path}")


if __name__ == "__main__":
    main()
