#!/usr/bin/env python3
"""X Timeline Fetcher — 抓取小鈴鐺用戶的實際推文"""

import argparse
import html
import json
import os
import re as _re
import signal
import sys
import time
from datetime import datetime, timedelta as _timedelta, timezone as _tz
from pathlib import Path

import httpx

try:
    from google import genai
except ImportError:
    genai = None

try:
    import resend as resend_mod
except ImportError:
    resend_mod = None

try:
    import boto3
except ImportError:
    boto3 = None

try:
    from generate_rss import generate_feed
except ImportError:
    generate_feed = None

OUTPUT_DIR = Path(__file__).parent / "output"
OUTPUT_DIR.mkdir(exist_ok=True)

NOTIFICATIONS_URL = "https://x.com/i/api/graphql/2yjiL84hBkOVpzJkkix6Mw/NotificationsTimeline"
TIMELINE_URL = "https://x.com/i/api/graphql/csRxUH5ocwnJtPnB3-wr4g/HomeLatestTimeline"

FEATURES = {
    "rweb_video_screen_enabled": False,
    "profile_label_improvements_pcf_label_in_post_enabled": True,
    "responsive_web_profile_redirect_enabled": False,
    "rweb_tipjar_consumption_enabled": False,
    "verified_phone_label_enabled": False,
    "creator_subscriptions_tweet_preview_api_enabled": True,
    "responsive_web_graphql_timeline_navigation_enabled": True,
    "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
    "premium_content_api_read_enabled": False,
    "communities_web_enable_tweet_community_results_fetch": True,
    "c9s_tweet_anatomy_moderator_badge_enabled": True,
    "responsive_web_grok_analyze_button_fetch_trends_enabled": False,
    "responsive_web_grok_analyze_post_followups_enabled": True,
    "responsive_web_jetfuel_frame": True,
    "responsive_web_grok_share_attachment_enabled": True,
    "responsive_web_grok_annotations_enabled": True,
    "articles_preview_enabled": True,
    "responsive_web_edit_tweet_api_enabled": True,
    "graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
    "view_counts_everywhere_api_enabled": True,
    "longform_notetweets_consumption_enabled": True,
    "responsive_web_twitter_article_tweet_consumption_enabled": True,
    "tweet_awards_web_tipping_enabled": False,
    "content_disclosure_indicator_enabled": True,
    "content_disclosure_ai_generated_indicator_enabled": True,
    "responsive_web_grok_show_grok_translated_post": False,
    "responsive_web_grok_analysis_button_from_backend": True,
    "post_ctas_fetch_enabled": False,
    "freedom_of_speech_not_reach_fetch_enabled": True,
    "standardized_nudges_misinfo": True,
    "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
    "longform_notetweets_rich_text_read_enabled": True,
    "longform_notetweets_inline_media_enabled": True,
    "responsive_web_grok_image_annotation_enabled": True,
    "responsive_web_grok_imagine_annotation_enabled": True,
    "responsive_web_grok_community_note_auto_translation_is_enabled": False,
    "responsive_web_enhance_cards_enabled": False,
}


def load_env():
    env_path = Path(__file__).parent / ".env"
    if not env_path.exists():
        return
    for line in env_path.read_text().splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, _, value = line.partition("=")
        os.environ.setdefault(key.strip(), value.strip())


def build_headers() -> dict:
    auth_token = os.environ.get("AUTH_TOKEN", "")
    ct0 = os.environ.get("CT0", "")
    if not auth_token or not ct0:
        print("ERROR: AUTH_TOKEN 和 CT0 必須在 .env 中設定")
        sys.exit(1)
    return {
        "authorization": "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA",
        "x-csrf-token": ct0,
        "cookie": f"auth_token={auth_token}; ct0={ct0}",
        "content-type": "application/json",
        "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
        "x-twitter-active-user": "yes",
        "x-twitter-auth-type": "OAuth2Session",
        "x-twitter-client-language": "zh-tw",
    }


def parse_tweet(tweet_result: dict) -> dict | None:
    if tweet_result.get("__typename") == "TweetWithVisibilityResults":
        tweet_result = tweet_result.get("tweet", {})
    if tweet_result.get("__typename") != "Tweet":
        return None

    core = tweet_result.get("core", {})
    user_results = core.get("user_results", {}).get("result", {})
    legacy_user = user_results.get("legacy", {})
    user_core = user_results.get("core", {})
    legacy = tweet_result.get("legacy", {})

    full_text = legacy.get("full_text", "")
    note_tweet = tweet_result.get("note_tweet", {}).get("note_tweet_results", {}).get("result", {})
    if note_tweet:
        full_text = note_tweet.get("text", full_text)
    full_text = html.unescape(full_text)

    tweet_id = legacy.get("id_str") or tweet_result.get("rest_id", "")
    screen_name = legacy_user.get("screen_name", "") or user_core.get("screen_name", "")
    profile_image = legacy_user.get("profile_image_url_https", "") or user_results.get("avatar", {}).get("image_url", "")

    media_entities = legacy.get("extended_entities", {}).get("media", [])
    if not media_entities:
        media_entities = legacy.get("entities", {}).get("media", [])
    media_urls = []
    for m in media_entities:
        if m.get("type") in ("video", "animated_gif"):
            variants = m.get("video_info", {}).get("variants", [])
            mp4s = [v for v in variants if v.get("content_type") == "video/mp4"]
            if mp4s:
                best = max(mp4s, key=lambda v: v.get("bitrate", 0))
                media_urls.append(best["url"])
        else:
            media_urls.append(m.get("media_url_https", ""))

    created_at = legacy.get("created_at", "")
    if created_at:
        try:
            dt = datetime.strptime(created_at, "%a %b %d %H:%M:%S %z %Y")
            created_at = dt.isoformat()
        except ValueError:
            pass

    return {
        "tweet_id": tweet_id,
        "author_handle": screen_name,
        "author_name": legacy_user.get("name", "") or user_core.get("name", ""),
        "author_avatar": profile_image,
        "full_text": full_text,
        "created_at": created_at,
        "tweet_url": f"https://x.com/{screen_name}/status/{tweet_id}" if screen_name and tweet_id else "",
        "likes": legacy.get("favorite_count", 0),
        "retweets": legacy.get("retweet_count", 0),
        "replies": legacy.get("reply_count", 0),
        "views": tweet_result.get("views", {}).get("count", ""),
        "media_urls": media_urls,
        "lang": legacy.get("lang", ""),
    }


def fetch_bell_users(client: httpx.Client) -> set[str]:
    """從 NotificationsTimeline 取得小鈴鐺用戶 handle"""
    variables = {"timeline_type": "All", "count": 40}
    params = {
        "variables": json.dumps(variables),
        "features": json.dumps(FEATURES),
    }
    resp = client.get(NOTIFICATIONS_URL, params=params)
    if resp.status_code != 200:
        print(f"  通知 API 失敗: HTTP {resp.status_code}")
        return set()

    data = resp.json()
    bell_handles = set()

    timeline = (
        data.get("data", {})
        .get("viewer_v2", {})
        .get("user_results", {})
        .get("result", {})
        .get("notification_timeline", {})
        .get("timeline", {})
    )

    for instruction in timeline.get("instructions", []):
        if instruction.get("type") != "TimelineAddEntries":
            continue
        for entry in instruction.get("entries", []):
            content = entry.get("content", {})
            element = content.get("clientEventInfo", {}).get("element", "")
            if element != "device_follow_tweet_notification_entry":
                continue
            item = content.get("itemContent", {})
            tmpl = item.get("template", {})
            for u in tmpl.get("from_users", []):
                ur = u.get("user_results", {}).get("result", {})
                handle = ur.get("core", {}).get("screen_name", "")
                if handle:
                    bell_handles.add(handle.lower())

    return bell_handles


def fetch_timeline(client: httpx.Client) -> list[dict]:
    """從 HomeLatestTimeline 取得實際推文"""
    body = {
        "queryId": "csRxUH5ocwnJtPnB3-wr4g",
        "variables": {
            "count": 40,
            "enableRanking": False,
            "includePromotedContent": False,
            "requestContext": "launch",
        },
        "features": FEATURES,
    }

    resp = client.post(TIMELINE_URL, json=body)

    if resp.status_code in (401, 403):
        print(f"ERROR: HTTP {resp.status_code} — auth token 可能已過期，請更新 .env")
        sys.exit(1)
    if resp.status_code == 429:
        print("  Rate limited，等待 60 秒...")
        time.sleep(60)
        return []
    if resp.status_code != 200:
        print(f"  ERROR: HTTP {resp.status_code}: {resp.text[:300]}")
        return []

    data = resp.json()
    if "errors" in data:
        print(f"  API error: {data['errors']}")
        return []

    tweets = []
    timeline = data.get("data", {}).get("home", {}).get("home_timeline_urt", {})

    for instruction in timeline.get("instructions", []):
        entries = instruction.get("entries", [])
        for entry in entries:
            entry_id = entry.get("entryId", "")
            if not entry_id.startswith("tweet-") and not entry_id.startswith("homeConversation-"):
                continue

            content = entry.get("content", {})

            if content.get("entryType") == "TimelineTimelineItem":
                item = content.get("itemContent", {})
                if item.get("itemType") == "TimelineTweet":
                    tr = item.get("tweet_results", {}).get("result", {})
                    parsed = parse_tweet(tr)
                    if parsed:
                        tweets.append(parsed)

            elif content.get("entryType") == "TimelineTimelineModule":
                for sub in content.get("items", []):
                    sub_item = sub.get("item", {}).get("itemContent", {})
                    if sub_item.get("itemType") == "TimelineTweet":
                        tr = sub_item.get("tweet_results", {}).get("result", {})
                        parsed = parse_tweet(tr)
                        if parsed:
                            tweets.append(parsed)

    return tweets


def summarize_tweets(tweets: list[dict]) -> str | None:
    """用 Gemini Flash 對一批推文生成繁體中文摘要"""
    api_key = os.environ.get("GEMINI_API_KEY", "")
    if not api_key or not genai:
        return None

    texts = []
    for t in tweets:
        line = f"@{t['author_handle']}: {t['full_text']}"
        if t.get("likes"):
            line += f" [{t['likes']} likes]"
        texts.append(line)

    prompt = (
        "你是加密貨幣推特動態分析師。以下是過去一小時內來自我關注帳號的推文。\n"
        "直接輸出結果，不要開頭說「好的」或自我介紹。用繁體中文回覆，嚴格遵守以下格式：\n\n"
        "## 摘要\n"
        "3-5 句話概括主要動態。優先順序：加密市場 > 宏觀經濟 > 地緣政治。不要逐條翻譯。\n"
        "提到代幣時一律用 $TICKER 格式（如 $BTC、$ETH、$SOL），不要用全名。\n\n"
        "## 交易信號\n"
        "列出推文中提到的潛在交易機會，每個一行，嚴格使用此格式：\n"
        "- $TOKEN: 原因（若有合約地址請附上）\n"
        "TOKEN 必須是代幣符號（如 BTC、ETH、WOJAK），不要用專案全名或中文名。\n"
        "包括：新上線代幣、被多人提及的幣種、重大政策/監管變動相關的資產。\n"
        "如果沒有明確的交易信號，寫「無明顯交易信號」。\n"
        "必須輸出此段落，不可省略。\n\n"
        + "\n---\n".join(texts)
    )

    client = genai.Client(api_key=api_key)
    for attempt in range(2):
        try:
            response = client.models.generate_content(
                model="gemini-2.5-flash-lite",
                contents=prompt,
            )
            return response.text.strip()
        except Exception as exc:
            if attempt == 0 and "503" in str(exc):
                print(f"  Gemini 503，30 秒後重試...")
                time.sleep(30)
            else:
                print(f"  Gemini API error: {exc}")
                return None


_TOKEN_BLACKLIST = {
    "USD", "USDT", "USDC", "DAI", "THE", "AND", "FOR", "NOT", "ALL",
    "ARE", "BUT", "HAS", "WAS", "CAN", "MAY", "NOW", "NEW", "TOP",
    "BINANCE", "GRAYSCALE", "POLYMARKET", "OPENCLAW", "ESPRESSO", "PALANTIR",
}


def _extract_tokens(text: str) -> list[str]:
    """從文字中提取代幣提及，去重保序。支援 $TOKEN、**TOKEN**、信號行首 token。"""
    seen: set[str] = set()
    tokens: list[str] = []

    def _add(tok: str):
        tok = tok.upper()
        if 2 <= len(tok) <= 12 and tok not in seen and tok not in _TOKEN_BLACKLIST:
            seen.add(tok)
            tokens.append(tok)

    for m in _re.finditer(r"\$([A-Za-z]{2,12})", text):
        _add(m.group(1))
    for m in _re.finditer(r"\*\*([A-Za-z]{2,12})\*\*", text):
        _add(m.group(1))
    for m in _re.finditer(r"^- ([A-Z][A-Za-z]{1,11})[\s:：(（]", text, _re.MULTILINE):
        _add(m.group(1))
    return tokens


def _extract_contracts(text: str) -> list[str]:
    """從文字中提取合約地址（0x 開頭，40 hex chars）。"""
    return list(dict.fromkeys(
        m.group(0).lower() for m in _re.finditer(r"0x[0-9a-fA-F]{40}", text)
    ))


def save_summary(summary_text: str, tweet_ids: list[str]):
    """將摘要存到 summaries.json（含結構化欄位）"""
    summaries_path = OUTPUT_DIR / "summaries.json"
    existing = []
    if summaries_path.exists():
        existing = json.loads(summaries_path.read_text(encoding="utf-8"))

    main_text, signals_text = _parse_summary_sections(summary_text)
    tokens = _extract_tokens(summary_text)
    contracts = _extract_contracts(summary_text)

    now_utc = datetime.now(_tz.utc)
    now_local = datetime.now()
    entry = {
        "id": now_local.strftime("%Y-%m-%d-%H%M"),
        "timestamp": now_utc.isoformat().replace("+00:00", "Z"),
        "summary": summary_text,
        "summary_text": main_text,
        "signals": signals_text,
        "tokens": tokens,
        "contracts": contracts,
        "tweet_ids": tweet_ids,
        "tweet_count": len(tweet_ids),
    }

    existing.insert(0, entry)
    cutoff_id = (now_local - _timedelta(days=7)).strftime("%Y-%m-%d-%H%M")
    existing = [e for e in existing if e.get("id", "") >= cutoff_id]
    existing.sort(key=lambda e: e.get("id", ""), reverse=True)
    summaries_path.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"✓ 摘要已儲存: {summaries_path}")

    if generate_feed:
        generate_feed()


def upload_to_r2():
    """上傳 summaries.json 和 notifications.json 到 Cloudflare R2"""
    endpoint = os.environ.get("R2_ENDPOINT", "")
    key_id = os.environ.get("R2_ACCESS_KEY_ID", "")
    secret = os.environ.get("R2_SECRET_ACCESS_KEY", "")
    bucket = os.environ.get("R2_BUCKET", "bell-feed")

    if not endpoint or not key_id or not secret or not boto3:
        return

    try:
        s3 = boto3.client(
            "s3",
            endpoint_url=endpoint,
            aws_access_key_id=key_id,
            aws_secret_access_key=secret,
            region_name="auto",
        )
        upload_files = {
            "summaries.json": "application/json",
            "notifications.json": "application/json",
            "daily_tldr.json": "application/json",
            "feed.xml": "application/rss+xml",
        }
        for fname, content_type in upload_files.items():
            fpath = OUTPUT_DIR / fname
            if fpath.exists():
                s3.upload_file(
                    str(fpath), bucket, fname,
                    ExtraArgs={"ContentType": content_type},
                )
        print("✓ R2 上傳完成")
    except Exception as exc:
        print(f"  R2 上傳失敗: {exc}")


def _parse_summary_sections(raw: str) -> tuple[str, str]:
    """Parse ## 摘要 / ## 交易信號 from AI output, return (main, signals)."""
    if not raw:
        return ("", "")
    summary_m = _re.search(r"##\s*摘要\s*\n([\s\S]*?)(?=\n##|$)", raw)
    signal_m = _re.search(r"##\s*交易信號\s*\n([\s\S]*?)(?=\n##|$)", raw)
    main = summary_m.group(1).strip() if summary_m else _re.sub(
        r"##\s*交易信號[\s\S]*$", "", _re.sub(r"##\s*摘要\s*\n?", "", raw)
    ).strip()
    signals = signal_m.group(1).strip() if signal_m else ""
    if signals in ("無明顯交易信號", "無明顯交易信號。"):
        signals = ""
    return (main, signals)


def send_daily_digest(override_to: str = ""):
    """彙整過去 24 小時的摘要，寄一封 email"""
    api_key = os.environ.get("RESEND_API_KEY", "")
    email_to = override_to or os.environ.get("EMAIL_TO", "")
    if not api_key or not email_to or not resend_mod:
        print("  跳過 email（未設定 RESEND_API_KEY/EMAIL_TO）")
        return False

    summaries_path = OUTPUT_DIR / "summaries.json"
    if not summaries_path.exists():
        print("  無摘要可寄")
        return False

    all_summaries = json.loads(summaries_path.read_text(encoding="utf-8"))

    now = datetime.now()
    yesterday = now.timestamp() - 86400
    recent = []
    for s in all_summaries:
        try:
            ts = datetime.fromisoformat(s["timestamp"]).timestamp()
            if ts >= yesterday:
                recent.append(s)
        except (KeyError, ValueError):
            continue

    if not recent:
        print("  過去 24 小時無摘要")
        return False

    today_str = now.strftime("%Y-%m-%d")
    total_tweets = sum(s.get("tweet_count", 0) for s in recent)

    all_tweets = []
    tweets_path = OUTPUT_DIR / "notifications.json"
    if tweets_path.exists():
        all_tweets = json.loads(tweets_path.read_text(encoding="utf-8"))
    tweet_map = {t["tweet_id"]: t for t in all_tweets if t.get("tweet_id")}

    tldr_text = ""
    gemini_key = os.environ.get("GEMINI_API_KEY", "")
    if gemini_key and genai and len(recent) > 1:
        all_summary_text = "\n".join(s.get("summary", "") for s in recent)
        try:
            client = genai.Client(api_key=gemini_key)
            tldr_resp = client.models.generate_content(
                model="gemini-2.5-flash-lite",
                contents=(
                    "以下是今天多個時段的推特摘要，請用繁體中文濃縮成 2-3 句話的當日總結，"
                    "重點放在市場動態和重要事件：\n\n" + all_summary_text
                ),
            )
            tldr_text = tldr_resp.text.strip()
        except Exception:
            pass

    if tldr_text:
        tldr_path = OUTPUT_DIR / "daily_tldr.json"
        tldr_path.write_text(json.dumps({
            "date": today_str,
            "text": tldr_text,
        }, ensure_ascii=False), encoding="utf-8")

    tldr_html = ""
    if tldr_text:
        tldr_html = (
            f'<div style="margin-bottom:24px;padding:16px 20px;background-color:#eff6ff;border-radius:10px;border:1px solid #bfdbfe;">'
            f'<div style="font-size:12px;font-weight:600;color:#3b82f6;margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px;">TL;DR</div>'
            f'<div style="font-size:15px;line-height:1.7;color:#1f2937;">{html.escape(tldr_text)}</div>'
            f'</div>'
        )

    summary_blocks = []
    for s in recent:
        time_label = ""
        try:
            dt = datetime.fromisoformat(s["timestamp"])
            time_label = dt.strftime("%H:%M")
        except (KeyError, ValueError):
            time_label = s.get("id", "")

        main_text, signals_text = _parse_summary_sections(s.get("summary", ""))
        main_html = html.escape(main_text).replace("\n", "<br>")

        signal_html = ""
        if signals_text:
            signal_lines = html.escape(signals_text).replace("\n", "<br>")
            signal_html = (
                f'<div style="margin-top:12px;padding:10px 14px;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;">'
                f'<div style="font-size:11px;font-weight:700;color:#d97706;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Trading Signals</div>'
                f'<div style="font-size:13.5px;line-height:1.7;color:#92400e;">{signal_lines}</div>'
                f'</div>'
            )

        summary_blocks.append(
            f'<div style="margin-bottom:16px;padding:16px 18px;background:#f0f4ff;border-radius:10px;border-left:4px solid #3b82f6;">'
            f'<div style="font-size:12px;font-weight:600;color:#6b7280;margin-bottom:8px;">'
            f'<span style="color:#3b82f6;">{time_label}</span> &middot; {s.get("tweet_count", 0)} tweets</div>'
            f'<div style="font-size:15px;line-height:1.7;color:#1e293b;">{main_html}</div>'
            f'{signal_html}'
            f'</div>'
        )

    grouped_sections = []
    for s in recent:
        time_label = ""
        try:
            dt = datetime.fromisoformat(s["timestamp"])
            time_label = dt.strftime("%H:%M")
        except (KeyError, ValueError):
            time_label = s.get("id", "")

        tweets_in_group = [tweet_map[tid] for tid in s.get("tweet_ids", []) if tid in tweet_map]
        tweets_in_group.sort(key=lambda t: t.get("created_at", ""), reverse=True)

        if not tweets_in_group:
            continue

        tweet_cards = []
        for t in tweets_in_group:
            tweet_time = ""
            if t.get("created_at"):
                try:
                    dt2 = datetime.fromisoformat(t["created_at"])
                    tweet_time = dt2.strftime("%H:%M")
                except ValueError:
                    pass

            stats_parts = []
            if t.get("likes"): stats_parts.append(f'&hearts; {t["likes"]}')
            if t.get("retweets"): stats_parts.append(f'&#x1f501; {t["retweets"]}')
            if t.get("views"): stats_parts.append(f'&#x1f441; {t["views"]}')
            stats_str = " &nbsp;&middot;&nbsp; ".join(stats_parts)

            tweet_url = t.get("tweet_url", "")
            name_html = html.escape(t.get("author_name", ""))
            handle_html = html.escape(t.get("author_handle", ""))
            text_html = html.escape(t.get("full_text", "")).replace("\n", "<br>")
            avatar_url = t.get("author_avatar", "")

            avatar_html = ""
            if avatar_url:
                avatar_html = (
                    f'<img src="{html.escape(avatar_url)}" '
                    f'style="width:36px;height:36px;border-radius:50%;margin-right:10px;vertical-align:middle;" alt="">'
                )

            media_html = ""
            for url in t.get("media_urls", []):
                if ".mp4" in url:
                    media_html += f'<div style="margin:8px 0;"><a href="{html.escape(url)}" style="color:#3b82f6;font-size:13px;">&#x25b6; Video</a></div>'
                else:
                    media_html += f'<img src="{html.escape(url)}" style="max-width:100%;border-radius:8px;margin:8px 0;display:block;" alt="">'

            tweet_cards.append(
                f'<div style="margin-bottom:8px;padding:12px;background:#ffffff;border:1px solid #e5e7eb;border-radius:8px;">'
                f'<div style="margin-bottom:6px;display:flex;align-items:center;">'
                f'{avatar_html}'
                f'<div>'
                f'<a href="{tweet_url}" style="color:#1f2937;font-weight:600;font-size:14px;text-decoration:none;">{name_html}</a>'
                f' <span style="color:#6b7280;font-size:13px;">@{handle_html} &middot; {tweet_time}</span>'
                f'</div></div>'
                f'<div style="font-size:14px;line-height:1.65;color:#374151;margin-bottom:6px;">{text_html}</div>'
                f'{media_html}'
                f'<div style="font-size:12px;color:#6b7280;">{stats_str}</div>'
                f'</div>'
            )

        grouped_sections.append(
            f'<div style="margin-top:24px;margin-bottom:16px;">'
            f'<div style="font-size:13px;font-weight:600;color:#6b7280;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid #e5e7eb;">'
            f'{time_label} &middot; {len(tweets_in_group)} tweets</div>'
            + "".join(tweet_cards)
            + '</div>'
        )

    html_body = f"""
    <!DOCTYPE html>
    <html><head>
    <meta name="color-scheme" content="light only">
    <meta name="supported-color-schemes" content="light only">
    </head><body style="background-color:#ffffff;margin:0;padding:16px;">
    <div style="max-width:600px;margin:0 auto;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#1e293b;">
      <div style="padding:20px 0 16px;border-bottom:2px solid #3b82f6;margin-bottom:20px;">
        <h2 style="margin:0 0 4px;font-size:22px;color:#0f172a;">
          &#x1F514; Bell Feed Daily Digest
        </h2>
        <p style="margin:0;color:#64748b;font-size:13px;">{today_str} &middot; {len(recent)} summaries &middot; {total_tweets} tweets</p>
      </div>
      {tldr_html}
      <h3 style="color:#0f172a;font-size:16px;margin:24px 0 12px;padding-bottom:6px;border-bottom:1px solid #e2e8f0;">
        &#x1F4CA; Hourly Summaries
      </h3>
      {''.join(summary_blocks)}
      <h3 style="color:#0f172a;font-size:16px;margin:28px 0 8px;padding-bottom:6px;border-bottom:1px solid #e2e8f0;">
        &#x1F426; Tweets
      </h3>
      {''.join(grouped_sections)}
      <div style="margin-top:32px;padding-top:16px;border-top:1px solid #e2e8f0;text-align:center;">
        <p style="color:#94a3b8;font-size:11px;margin:0;">
          Powered by <strong style="color:#64748b;">degendar.com</strong> &middot; Auto-generated digest
        </p>
      </div>
    </div>
    </body></html>
    """

    try:
        resend_mod.api_key = api_key
        resend_mod.Emails.send({
            "from": "Bell Feed <digest@degendar.com>",
            "to": [addr.strip() for addr in email_to.split(",")],
            "subject": f"X Bell Feed Digest — {today_str} ({total_tweets} tweets)",
            "html": html_body,
        })
        print(f"✓ Daily digest 已寄送: {email_to}")
        return True
    except Exception as exc:
        print(f"  Email 發送失敗: {exc}")
        return False


def fetch_once(headers: dict) -> list[dict]:
    """執行一次抓取，回傳本次新增的推文列表"""
    json_path = OUTPUT_DIR / "notifications.json"
    existing: list[dict] = []
    existing_ids: set[str] = set()
    if json_path.exists():
        existing = json.loads(json_path.read_text(encoding="utf-8"))
        existing_ids = {t["tweet_id"] for t in existing if t.get("tweet_id")}
        print(f"→ 載入既有資料: {len(existing)} 則")

    with httpx.Client(headers=headers, timeout=30) as client:
        print("→ 抓取小鈴鐺用戶列表...")
        bell_users = fetch_bell_users(client)
        if bell_users:
            print(f"  小鈴鐺用戶: {', '.join(sorted(bell_users))}")
        else:
            print("  未找到小鈴鐺用戶，將顯示全部 timeline")

        time.sleep(1)

        print("→ 抓取 timeline 推文...")
        timeline_tweets = fetch_timeline(client)
        print(f"  取得 {len(timeline_tweets)} 則推文")

    if bell_users:
        filtered = [t for t in timeline_tweets if t["author_handle"].lower() in bell_users]
        print(f"  小鈴鐺用戶推文: {len(filtered)} 則")
    else:
        filtered = timeline_tweets

    new_tweets = [t for t in filtered if t["tweet_id"] and t["tweet_id"] not in existing_ids]

    if new_tweets:
        print(f"→ 新增 {len(new_tweets)} 則")
        all_tweets = new_tweets + existing
    else:
        print("→ 沒有新推文")
        all_tweets = existing

    all_tweets.sort(key=lambda t: t.get("created_at", ""), reverse=True)

    cutoff = (datetime.now() - _timedelta(days=7)).isoformat()
    before = len(all_tweets)
    all_tweets = [t for t in all_tweets if t.get("created_at", "") >= cutoff]
    if before > len(all_tweets):
        print(f"  清理 {before - len(all_tweets)} 則 > 7 天的推文")

    json_path.write_text(json.dumps(all_tweets, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"→ 總計 {len(all_tweets)} 則")
    print(f"✓ 儲存: {json_path}")

    return new_tweets


def main():
    parser = argparse.ArgumentParser(description="抓取小鈴鐺用戶推文")
    parser.add_argument("--loop", action="store_true", help="持續每小時抓取")
    parser.add_argument("--interval", type=int, default=3600, help="loop 間隔秒數（預設 3600）")
    args = parser.parse_args()

    load_env()
    headers = build_headers()
    print("→ 從 .env 讀取 auth credentials")

    if not args.loop:
        new_tweets = fetch_once(headers)
        if new_tweets:
            print("→ 生成 AI 摘要...")
            summary = summarize_tweets(new_tweets)
            if summary:
                print(f"  摘要: {summary}")
                save_summary(summary, [t["tweet_id"] for t in new_tweets])
            else:
                print("  跳過摘要（未設定 GEMINI_API_KEY 或 API 錯誤）")
        upload_to_r2()
        return

    stop = False

    def handle_signal(sig, frame):
        nonlocal stop
        print("\n→ 收到停止信號，完成後退出...")
        stop = True

    signal.signal(signal.SIGINT, handle_signal)
    signal.signal(signal.SIGTERM, handle_signal)

    print(f"→ Loop 模式：每 {args.interval} 秒抓取一次（Ctrl+C 停止）")
    print("→ Daily digest: 每天 08:00 寄送")

    startup_to = os.environ.get("EMAIL_TO_STARTUP", "")
    if startup_to:
        print(f"→ 啟動時寄送 digest 給 {startup_to}...")
        send_daily_digest(override_to=startup_to)

    digest_sent_path = OUTPUT_DIR / ".digest_sent"
    last_digest_date = ""
    if digest_sent_path.exists():
        last_digest_date = digest_sent_path.read_text().strip()

    while not stop:
        now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        print(f"\n{'='*50}")
        print(f"[{now}] 開始抓取")
        print(f"{'='*50}")

        try:
            new_tweets = fetch_once(headers)
            if new_tweets:
                print("→ 生成 AI 摘要...")
                summary = summarize_tweets(new_tweets)
                if summary:
                    print(f"  摘要: {summary[:100]}...")
                    save_summary(summary, [t["tweet_id"] for t in new_tweets])
                else:
                    print("  跳過摘要（未設定 GEMINI_API_KEY 或 API 錯誤）")

            upload_to_r2()

            current_hour = datetime.now().hour
            current_date = datetime.now().strftime("%Y-%m-%d")
            if current_hour >= 8 and current_date != last_digest_date:
                print("→ 寄送 daily digest...")
                if send_daily_digest():
                    last_digest_date = current_date
                    digest_sent_path.write_text(current_date)
        except Exception as exc:
            print(f"  ERROR: {exc}")

        if stop:
            break

        print(f"→ 下次抓取: {args.interval} 秒後")
        for _ in range(args.interval):
            if stop:
                break
            time.sleep(1)

    print("✓ 已停止")


if __name__ == "__main__":
    main()
