"""RCI social-media ingestion — the organisation's own public output.

Feeds the **political** section alongside the RCI/Der Funke RSS already in
feeds/curated-feeds.opml, tagged `"category": "theory_social"` so
synth/prompts/political.md can tell the section's agitational and organisational
posts ("come to this meeting", "watch this explainer") apart from its written
analysis, which stays tagged `"theory"`. Sources are listed in the committed,
user-editable feeds/rci-social.json.

WHAT IS REACHABLE WITHOUT SCRAPING, AND WHAT IS NOT
---------------------------------------------------
Only platforms with a public, keyless, first-party read path are implemented.
That is a hard line, not a to-do list:

  - **YouTube** — `https://www.youtube.com/feeds/videos.xml?channel_id=UC...`
    is YouTube's own Atom feed, no key and no quota. Verified live against both
    configured channels 2026-08-06.
  - **Any RSS/Atom feed** — a Mastodon account's `.rss`, a podcast feed, a
    section's own site. Same `feedparser` this project already vendors for news.
  - **Telegram** — read through the Telethon session `telegram_ingest.py`
    already logs in with. A public channel resolves by username and its history
    reads **without joining it**: no `JoinChannelRequest`, no
    `send_read_acknowledge()`, nothing written anywhere. If that session does
    not exist, these entries are skipped and the RSS ones still run.

Deliberately NOT built, because none of them has a read path that is both
keyless and within the platform's terms — and this component's whole premise is
that it never scrapes and never logs in as a person to a platform that forbids
it (the one exception, WhatsApp, is opt-in and carries its own ban warning in
README.md):

  - **Instagram** (`@rkp_austria`, `@revcomintern`) — the Basic Display API was
    retired in December 2024, and the Graph API only reads accounts you own,
    through a reviewed Meta app. Reading somebody else's public account means
    scraping, which is both against Instagram's terms and the fastest way to get
    an account or an IP blocked. This is the biggest real gap in this module:
    Instagram is where the section posts most.
  - **Facebook** (`/derfunke.at`) — page RSS was killed in 2018; Page Public
    Content Access needs Meta app review for a business use case this is not.
  - **WhatsApp channel** — no API of any kind. The existing `whatsapp-bridge`
    sidecar is a linked *personal* device and its library's channel support is
    experimental; wiring it up would extend that sidecar's ban risk to a feature
    that can be had by reading the website instead.
  - **X/Twitter** (`@revcomintern`) — the free API tier is write-oriented and
    reads essentially nothing; Nitter instances are gone.

If you want those, the honest answer is to follow the accounts on your phone.
Do not "solve" it here by pointing this module at a scraping proxy: that moves
the terms-of-service problem onto a third party without removing it.
"""

import html
import json
import logging
import os
import re
from datetime import datetime, timedelta, timezone

LOG = logging.getLogger(__name__)

DEFAULT_CONF_PATH = "/app/feeds/rci-social.json"

# YouTube's own feed endpoint. Documented and keyless, but it takes the opaque
# channel id (`UC...`) only — an `@handle` is not accepted, so the config file
# holds ids and says where to find them.
YOUTUBE_FEED_URL = "https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"

MAX_BODY_CHARS = 1200
DEFAULT_MAX_PER_SOURCE = 10

# What a source publishes, unless its config entry says otherwise. A YouTube channel
# is episodes by definition; a plain feed could be either (a podcast is episodes, a
# Mastodon account is posts), so it defaults to the less presumptuous of the two.
DEFAULT_CONTENT_TYPES = {"youtube": "episode", "rss": "post", "telegram": "post"}

_TAG_RE = re.compile(r"<[^>]+>")


# Deliberately duplicated from news_rss.py rather than shared: every module in
# this package stands on its own so that one source's breakage can never reach
# another's, and a shared helpers module would be a single import for all of them
# to fail on. Same trade counter_run.py makes against llm_client.py.
def _plain_text(markup):
    return html.unescape(_TAG_RE.sub(" ", markup or "")).strip()


def _entry_time(entry):
    parsed = entry.get("published_parsed") or entry.get("updated_parsed")
    if not parsed:
        return None
    import calendar

    return datetime.fromtimestamp(calendar.timegm(parsed), tz=timezone.utc)


def _load_sources(path):
    try:
        with open(path, encoding="utf-8") as handle:
            config = json.load(handle)
    except FileNotFoundError:
        LOG.warning("rci_social: %s not found, skipping", path)
        return []
    except Exception:
        LOG.warning("rci_social: %s could not be parsed, skipping", path, exc_info=True)
        return []

    sources = []
    for entry in config.get("sources") or []:
        if not isinstance(entry, dict) or entry.get("enabled") is False:
            continue
        platform = str(entry.get("platform") or "").strip().lower()
        if platform not in ("rss", "youtube", "telegram"):
            LOG.warning("rci_social: unknown platform %r, skipping that entry", platform)
            continue
        sources.append(
            {
                "platform": platform,
                "name": str(entry.get("name") or "").strip() or platform,
                # "episode" (something to watch or listen to later) vs "post"
                # (something said now). The political prompt keeps episodes out of
                # the analysis and puts them in their own watch-later window, so a
                # 40-minute video never competes with a strike for space.
                "content_type": str(entry.get("content_type") or DEFAULT_CONTENT_TYPES.get(platform, "post")).strip().lower(),
                # `scope` is passed straight through to the prompt: "section" is the
                # user's own national section, "international" the RCI as a whole.
                # The two are not the same voice and the digest should not merge them.
                "scope": str(entry.get("scope") or "section").strip().lower(),
                "url": str(entry.get("url") or "").strip(),
                "channel_id": str(entry.get("channel_id") or "").strip(),
                "channel": str(entry.get("channel") or "").strip().lstrip("@"),
            }
        )
    return sources


def _item(source, title, body, url, timestamp):
    return {
        "source": "rci_social",
        # Not "theory": these are posts, not the written analysis the political
        # prompt reasons from. See that prompt's "The organisation's own social
        # media" section for the difference it is told to keep.
        "category": "theory_social",
        "platform": source["platform"],
        "account": source["name"],
        "scope": source["scope"],
        "content_type": source["content_type"],
        "title": title,
        "url": url,
        "timestamp": timestamp,
        "body": (body or "")[:MAX_BODY_CHARS],
    }


def _fetch_feed(source, since, max_per_source):
    import feedparser

    url = source["url"]
    if source["platform"] == "youtube":
        if not source["channel_id"]:
            LOG.warning("rci_social: %s has no channel_id, skipping", source["name"])
            return []
        url = YOUTUBE_FEED_URL.format(channel_id=source["channel_id"])
    if not url:
        LOG.warning("rci_social: %s has no url, skipping", source["name"])
        return []

    parsed = feedparser.parse(url)
    items = []
    for entry in parsed.entries[:max_per_source]:
        published = _entry_time(entry)
        if published is None or published < since:
            continue
        item = _item(
            source,
            _plain_text(entry.get("title")),
            _plain_text(entry.get("summary") or entry.get("description")),
            entry.get("link") or url,
            published.isoformat(),
        )
        # Podcast feeds carry <itunes:duration>; YouTube's Atom feed does not. Passed
        # through when it exists because "is this 8 minutes or 90" is most of what
        # decides whether something makes it onto a watch-later list.
        duration = entry.get("itunes_duration")
        if duration:
            item["duration"] = str(duration)
        items.append(item)
    return items


async def _fetch_telegram_async(sources, since, api_id, api_hash, session_path, max_per_source):
    from telethon import TelegramClient

    items = []
    client = TelegramClient(session_path, api_id, api_hash)
    await client.connect()
    try:
        if not await client.is_user_authorized():
            LOG.warning(
                "rci_social: telegram session at %s is not authorized — run "
                "`python ingest/telegram_login.py` once; skipping the telegram sources",
                session_path,
            )
            return []

        for source in sources:
            try:
                # Resolving a public channel by username is a read. Nothing here
                # joins it, subscribes to it, or marks anything read — same
                # invariant as telegram_ingest.py, which never calls
                # send_read_acknowledge() either.
                entity = await client.get_entity(source["channel"])
                async for message in client.iter_messages(entity, limit=max_per_source):
                    if message.date is None or message.date < since:
                        break
                    if not message.message:
                        continue
                    items.append(
                        _item(
                            source,
                            "",
                            message.message,
                            f"https://t.me/{source['channel']}/{message.id}",
                            message.date.isoformat(),
                        )
                    )
            except Exception:
                LOG.warning(
                    "rci_social: could not read telegram channel %r, skipping",
                    source["channel"],
                    exc_info=True,
                )
    finally:
        await client.disconnect()

    return items


def _fetch_telegram(sources, since, max_per_source):
    api_id = os.environ.get("TELEGRAM_API_ID", "").strip()
    api_hash = os.environ.get("TELEGRAM_API_HASH", "").strip()
    session_path = os.environ.get("TELEGRAM_SESSION_PATH", "/data/telegram.session")

    # The same credentials and session file telegram_ingest.py uses. A household
    # that never set Telegram up simply doesn't get these sources; it is not an
    # error and must not cost the RSS ones.
    if not (api_id and api_hash and os.path.exists(session_path)):
        LOG.info("rci_social: no usable telegram session, skipping the telegram sources")
        return []

    import asyncio

    return asyncio.run(
        _fetch_telegram_async(sources, since, int(api_id), api_hash, session_path, max_per_source)
    )


def fetch(lookback_hours):
    sources = _load_sources(os.environ.get("RCI_SOCIAL_CONF_PATH", DEFAULT_CONF_PATH))
    if not sources:
        return []

    max_per_source = int(os.environ.get("RCI_SOCIAL_MAX_PER_SOURCE", DEFAULT_MAX_PER_SOURCE))
    since = datetime.now(timezone.utc) - timedelta(hours=float(lookback_hours))

    items = []
    for source in sources:
        if source["platform"] == "telegram":
            continue
        try:
            items.extend(_fetch_feed(source, since, max_per_source))
        except Exception:
            # One dead account never costs the others, exactly like a dead feed in
            # news_rss.py — a section that renamed a channel shouldn't silence the rest.
            LOG.warning("rci_social: %s failed, skipping it", source["name"], exc_info=True)

    telegram_sources = [source for source in sources if source["platform"] == "telegram"]
    if telegram_sources:
        try:
            items.extend(_fetch_telegram(telegram_sources, since, max_per_source))
        except Exception:
            LOG.warning("rci_social: telegram ingestion failed, skipping it", exc_info=True)

    items.sort(key=lambda item: item["timestamp"], reverse=True)
    LOG.info("rci_social: %d post(s) in the last %sh", len(items), lookback_hours)
    return items
