"""Extract Insta @bnk_fearxbob post metadata from Discord forum threads.

The insta_bot creates one forum thread per IG post, with name=YYYY/MM/DD,
embeds, and image attachments. We use Discord REST API to enumerate threads
and pull each thread's initial message → caption + image URLs + timestamp.

Output:
- insta_meta.json: list of {thread_id, date_str, created_at_iso, name,
  caption, image_urls[]}
- Optional: download all images to insta_photos/{shortcode_or_threadid}/

Usage:
  set DISCORD_TOKEN
  python extract_insta_from_discord.py [--download]
"""
import json
import os
import sys
import time
import urllib.request
import urllib.parse
from pathlib import Path

if hasattr(sys.stdout, 'reconfigure'):
    sys.stdout.reconfigure(encoding='utf-8')

HERE = Path(__file__).resolve().parent
OUT_META = HERE / 'insta_meta.json'
PHOTOS_DIR = HERE / 'insta_photos'

TOKEN = os.environ.get('DISCORD_TOKEN', '').strip()
if not TOKEN:
    print('DISCORD_TOKEN env var required', file=sys.stderr)
    sys.exit(2)

FORUM_CHANNEL_ID = '1471028969359933542'  # Insta forum channel
API = 'https://discord.com/api/v10'

DOWNLOAD = '--download' in sys.argv[1:]


def discord_get(path: str, params: dict | None = None, retries: int = 5) -> dict | list:
    url = API + path
    if params:
        url += '?' + urllib.parse.urlencode(params)
    attempt = 0
    while True:
        req = urllib.request.Request(url, headers={
            'Authorization': f'Bot {TOKEN}',
            'User-Agent': 'fearx-photo-collector (1.0)',
        })
        try:
            with urllib.request.urlopen(req, timeout=20) as resp:
                return json.loads(resp.read())
        except urllib.error.HTTPError as e:
            if e.code == 429:
                body = json.loads(e.read())
                wait = float(body.get('retry_after', 1.0))
                print(f'  rate limited, wait {wait:.1f}s')
                time.sleep(wait + 0.2)
                continue
            if e.code in (502, 503, 504) and attempt < retries:
                attempt += 1
                wait = 2 ** attempt
                print(f'  HTTP {e.code} retry {attempt}/{retries} in {wait}s')
                time.sleep(wait)
                continue
            raise
        except (urllib.error.URLError, TimeoutError) as e:
            if attempt < retries:
                attempt += 1
                wait = 2 ** attempt
                print(f'  network error, retry {attempt}/{retries} in {wait}s: {e}')
                time.sleep(wait)
                continue
            raise


def list_archived_threads(channel_id: str) -> list[dict]:
    """Paginate /threads/archived/public to enumerate all archived forum threads."""
    out: list[dict] = []
    before = None
    while True:
        params = {'limit': 100}
        if before:
            params['before'] = before
        r = discord_get(f'/channels/{channel_id}/threads/archived/public', params)
        threads = r.get('threads', []) if isinstance(r, dict) else []
        if not threads:
            break
        out.extend(threads)
        if not r.get('has_more'):
            break
        # Pagination: use earliest archive_timestamp
        last_ts = threads[-1].get('thread_metadata', {}).get('archive_timestamp')
        if not last_ts:
            break
        before = last_ts
        time.sleep(0.3)
    return out


def list_active_threads(guild_id: str) -> list[dict]:
    """List all active threads in guild, filter to our forum channel."""
    r = discord_get(f'/guilds/{guild_id}/threads/active')
    threads = r.get('threads', []) if isinstance(r, dict) else []
    return [t for t in threads if t.get('parent_id') == FORUM_CHANNEL_ID]


def get_channel_info(channel_id: str) -> dict:
    return discord_get(f'/channels/{channel_id}')


def get_first_message(thread_id: str) -> dict | None:
    """The initial message of a forum thread = same ID as thread, fetched as message."""
    msgs = discord_get(f'/channels/{thread_id}/messages', {'limit': 1, 'after': '0'})
    if isinstance(msgs, list) and msgs:
        return msgs[-1]  # oldest = thread starter
    # Fallback: get latest if no messages with after=0
    msgs = discord_get(f'/channels/{thread_id}/messages', {'limit': 50})
    if isinstance(msgs, list) and msgs:
        return msgs[-1]
    return None


def get_all_messages(thread_id: str) -> list[dict]:
    """Paginate all messages in a thread (oldest → newest)."""
    out: list[dict] = []
    after = '0'
    while True:
        msgs = discord_get(f'/channels/{thread_id}/messages', {'limit': 100, 'after': after})
        if not isinstance(msgs, list) or not msgs:
            break
        # API returns newest first when no after (or oldest first with after?)
        # With after=X, returns messages with id > X, sorted by id ascending? Discord docs say messages are returned newest first by default.
        # Sort by id ascending for safety
        msgs.sort(key=lambda m: int(m['id']))
        out.extend(msgs)
        if len(msgs) < 100:
            break
        after = msgs[-1]['id']
        time.sleep(0.2)
    return out


def main():
    print('=== fetching forum channel info ===')
    ch = get_channel_info(FORUM_CHANNEL_ID)
    guild_id = ch.get('guild_id')
    print(f'guild: {guild_id}, parent name: {ch.get("name")}')

    print('=== listing active threads in guild (filtered to forum) ===')
    active = list_active_threads(guild_id) if guild_id else []
    print(f'  active: {len(active)}')

    print('=== listing archived threads in forum channel ===')
    archived = list_archived_threads(FORUM_CHANNEL_ID)
    print(f'  archived: {len(archived)}')

    all_threads = active + archived
    seen_ids = set()
    unique_threads = []
    for t in all_threads:
        tid = t.get('id')
        if tid and tid not in seen_ids:
            seen_ids.add(tid)
            unique_threads.append(t)
    print(f'=== total unique threads: {len(unique_threads)} ===')

    # Resume support: load existing partial output
    out = []
    seen_meta_ids = set()
    if OUT_META.exists():
        try:
            existing = json.loads(OUT_META.read_text(encoding='utf-8'))
            if isinstance(existing, list):
                out = existing
                seen_meta_ids = {e['thread_id'] for e in existing if 'thread_id' in e}
                print(f'(resume) loaded {len(out)} existing entries')
        except Exception:
            pass

    for i, t in enumerate(unique_threads):
        tid = t.get('id')
        tname = t.get('name', '')
        if tid in seen_meta_ids:
            print(f'[{i+1}/{len(unique_threads)}] skip (already): {tid} "{tname}"')
            continue
        # thread create timestamp (snowflake-derived)
        # Snowflake: ((id >> 22) + 1420070400000) ms
        ts_ms = (int(tid) >> 22) + 1420070400000
        from datetime import datetime, timezone, timedelta
        kst = datetime.fromtimestamp(ts_ms / 1000, tz=timezone(timedelta(hours=9)))
        print(f'[{i+1}/{len(unique_threads)}] thread {tid} "{tname}" @ {kst.isoformat()}')

        all_msgs = get_all_messages(tid)
        caption = ''
        image_urls: list[str] = []
        video_urls: list[str] = []
        for mi, msg in enumerate(all_msgs):
            if mi == 0:
                caption = msg.get('content', '') or ''
            for a in (msg.get('attachments') or []):
                ct = (a.get('content_type') or '').lower()
                fname = (a.get('filename') or '').lower()
                url = a.get('url') or a.get('proxy_url')
                if not url:
                    continue
                is_video = ct.startswith('video/') or any(fname.endswith(ext) for ext in ('.mp4', '.mov', '.webm', '.m4v'))
                is_image = ct.startswith('image/') or any(fname.endswith(ext) for ext in ('.png', '.jpg', '.jpeg', '.webp', '.gif'))
                if is_video:
                    if url not in video_urls:
                        video_urls.append(url)
                elif is_image:
                    if url not in image_urls:
                        image_urls.append(url)
            # only the first message has embeds we care about
            if mi == 0:
                for emb in (msg.get('embeds') or []):
                    if emb.get('image'):
                        u = emb['image'].get('url') or emb['image'].get('proxy_url')
                        if u and u not in image_urls:
                            image_urls.append(u)
                    if emb.get('thumbnail'):
                        u = emb['thumbnail'].get('url') or emb['thumbnail'].get('proxy_url')
                        if u and u not in image_urls:
                            image_urls.append(u)
                    if not caption and emb.get('description'):
                        caption = emb['description']

        # Video posts: insta_bot uploads a poster image_0.jpg as first attachment
        # AND the actual video as a follow-up. If a thread has video_urls, the
        # leading image is the poster — we keep BOTH so UI can show video first.
        entry = {
            'thread_id': tid,
            'thread_name': tname,
            'created_at_kst': kst.isoformat(),
            'created_at_ts': ts_ms,
            'caption': caption,
            'image_urls': image_urls,
            'video_urls': video_urls,
        }
        out.append(entry)
        print(f'  → {len(image_urls)} image(s), {len(video_urls)} video(s), caption[:60]={caption[:60]!r}')
        # checkpoint every 10 entries
        if (i + 1) % 10 == 0:
            OUT_META.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding='utf-8')
            print(f'  (checkpoint: saved {len(out)} entries)')
        time.sleep(0.25)  # be polite

    # Sort by timestamp (oldest first)
    out.sort(key=lambda x: x['created_at_ts'])

    OUT_META.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding='utf-8')
    print(f'\n=== wrote {OUT_META} ({len(out)} entries) ===')

    if DOWNLOAD:
        PHOTOS_DIR.mkdir(exist_ok=True)
        print(f'\n=== downloading media to {PHOTOS_DIR} ===')
        for e in out:
            tid = e['thread_id']
            d = PHOTOS_DIR / tid
            d.mkdir(exist_ok=True)
            for kind, urls, prefix in (('image', e['image_urls'], 'img'), ('video', e.get('video_urls') or [], 'vid')):
                for idx, url in enumerate(urls):
                    fname_base = url.split('?', 1)[0].rsplit('/', 1)[-1]
                    if not fname_base or len(fname_base) > 80:
                        ext = '.mp4' if kind == 'video' else '.jpg'
                        fname_base = f'{prefix}_{idx}{ext}'
                    dest = d / f'{idx:02d}_{fname_base}'
                    if dest.exists():
                        continue
                    # Atomic download: write to .part then rename (avoids leaving
                    # truncated files on disk if the network is interrupted mid-read)
                    part = dest.with_suffix(dest.suffix + '.part')
                    try:
                        req = urllib.request.Request(url, headers={'User-Agent': 'fearx-photo-collector'})
                        with urllib.request.urlopen(req, timeout=60) as resp:
                            expected = resp.headers.get('Content-Length')
                            data = resp.read()
                            if expected and int(expected) != len(data):
                                raise IOError(f'short read: got {len(data)}B expected {expected}B')
                            part.write_bytes(data)
                        part.replace(dest)  # atomic rename
                        print(f'  saved [{kind}]: {dest.name} ({len(data)}B)')
                    except Exception as ex:
                        try:
                            part.unlink(missing_ok=True)
                        except Exception:
                            pass
                        print(f'  download failed [{tid}/{kind}/{idx}]: {ex}', file=sys.stderr)


if __name__ == '__main__':
    main()
