#!/usr/bin/env python3
"""
SWAPA Podcast Analytics — Data Fetcher
Pulls episode download stats from Buzzsprout and Podbean APIs.
Outputs: data/podcast_stats.json  ← read by swapa_podcast_dashboard.html

Normal usage (fully automated — runs via launchd every Monday at 9am):
  python3 fetch_stats.py

Manual CSV override (fallback if Podbean API is unavailable):
  python3 fetch_stats.py --podbean ~/Downloads/podbean_export.csv
"""

import json
import time
import csv
import io
import argparse
import urllib.request
import urllib.parse
from datetime import datetime, timezone
from pathlib import Path

# ── CONFIG ────────────────────────────────────────────────────────────────────
BUZZSPROUT_TOKEN     = "12da8e465de1fba0dedbf981132472e8"
BUZZSPROUT_PODCAST   = "469237"

PODBEAN_CLIENT_ID    = "baf2841c7e9e83f77b2e4"
PODBEAN_CLIENT_SECRET= "97fc818f4a5c1b30884f7"
PODBEAN_PODCAST_ID   = "EmAEvYtOwC6J"

# Episodes on/after this date: Buzzsprout = app only, Podbean = external only
# Episodes before this date:   Buzzsprout = combined (no split available)
PLATFORM_SWITCH_DATE = "2026-03-23"

OUTPUT_DIR = Path(__file__).parent / "data"

# ── HELPERS ───────────────────────────────────────────────────────────────────
def http_get(url, headers=None):
    req = urllib.request.Request(url)
    req.add_header("User-Agent", "SWAPA-Analytics/1.0")
    for k, v in (headers or {}).items():
        req.add_header(k, v)
    with urllib.request.urlopen(req, timeout=30) as r:
        return r.read()

def http_post(url, data):
    encoded = urllib.parse.urlencode(data).encode()
    req = urllib.request.Request(url, data=encoded)
    req.add_header("User-Agent", "SWAPA-Analytics/1.0")
    req.add_header("Content-Type", "application/x-www-form-urlencoded")
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.loads(r.read())

def parse_date(s):
    """Return YYYY-MM-DD string from ISO 8601 or date-only input."""
    if not s:
        return ""
    s = str(s).strip()
    # Try common ISO formats explicitly — avoid slicing format strings
    for fmt in (
        "%Y-%m-%dT%H:%M:%S.%f+00:00",
        "%Y-%m-%dT%H:%M:%S+00:00",
        "%Y-%m-%dT%H:%M:%SZ",
        "%Y-%m-%dT%H:%M:%S",
        "%Y-%m-%d",
    ):
        try:
            return datetime.strptime(s[:len(fmt)], fmt).strftime("%Y-%m-%d")
        except ValueError:
            continue
    # Last resort: take first 10 chars if they look like a date
    return s[:10] if len(s) >= 10 else s

# ── BUZZSPROUT ────────────────────────────────────────────────────────────────
def fetch_buzzsprout():
    """Fetch all episodes and their total play counts from Buzzsprout API."""
    print("Fetching Buzzsprout episodes...")
    url = f"https://www.buzzsprout.com/api/{BUZZSPROUT_PODCAST}/episodes.json"
    raw = http_get(url, {"Authorization": f"Token token={BUZZSPROUT_TOKEN}"})
    episodes = json.loads(raw)
    print(f"  → {len(episodes)} total episodes")

    result = {}
    for ep in episodes:
        title = ep["title"].strip()
        result[title] = {
            "title":            title,
            "date":             parse_date(ep["published_at"]),
            "duration_sec":     ep["duration"],
            "buzzsprout_id":    ep["id"],
            "buzzsprout_plays": ep.get("total_downloads", ep.get("total_plays", 0)),  # Buzzsprout renamed total_plays -> total_downloads (Oct 2026)
        }
    return result

# ── PODBEAN API ───────────────────────────────────────────────────────────────
def get_podbean_token():
    """Request a fresh OAuth token. Tokens expire in 7 days; always re-fetch."""
    print("Authenticating with Podbean...")
    resp = http_post("https://api.podbean.com/v1/oauth/token", {
        "grant_type":    "client_credentials",
        "client_id":     PODBEAN_CLIENT_ID,
        "client_secret": PODBEAN_CLIENT_SECRET,
    })
    token = resp.get("access_token")
    if not token:
        raise RuntimeError(f"Podbean auth failed: {resp}")
    print("  → Token obtained")
    return token

def fetch_podbean_episodes(token):
    """List all episodes on the Podbean podcast (paginated)."""
    episodes = []
    offset = 0
    limit = 50
    while True:
        params = urllib.parse.urlencode({
            "access_token": token,
            "podcast_id":   PODBEAN_PODCAST_ID,
            "offset":       offset,
            "limit":        limit,
        })
        raw = http_get(f"https://api.podbean.com/v1/episodes?{params}")
        resp = json.loads(raw)
        batch = resp.get("episodes", [])
        episodes.extend(batch)
        if not resp.get("has_more") or not batch:
            break
        offset += limit
    return episodes

def podbean_get_with_retry(url, tries=5):
    """Podbean rate-limits bursts (HTTP 429). Pace calls and back off when limited."""
    for attempt in range(tries):
        try:
            time.sleep(0.3)
            return http_get(url)
        except urllib.error.HTTPError as e:
            if e.code == 429 and attempt < tries - 1:
                time.sleep(5 * (attempt + 1))
                continue
            raise

def fetch_podbean_downloads(token):
    """
    Pull per-episode download counts from Podbean.

    Podbean has no bulk "download report" endpoint — /v1/analytics/podcastReach
    (originally assumed) doesn't exist and 404s. The real endpoint is
    /v1/podcastStats/stats, which returns a per-month download breakdown for
    one episode_id at a time. Only post-switch episodes need Podbean data
    (external downloads), so we limit the per-episode calls to those.

    Returns dict keyed by episode title → {podbean_downloads, podbean_date}.
    """
    print("Fetching Podbean episode list...")
    episodes = fetch_podbean_episodes(token)
    print(f"  → {len(episodes)} Podbean episodes found")

    switch_dt = datetime.strptime(PLATFORM_SWITCH_DATE, "%Y-%m-%d").replace(tzinfo=timezone.utc)
    post_switch_eps = [
        ep for ep in episodes
        if datetime.fromtimestamp(ep["publish_time"], tz=timezone.utc) >= switch_dt
    ]
    print(f"  → {len(post_switch_eps)} post-switch episodes; fetching per-episode stats...")
    # Older (pre-switch) episodes also get public-player listens on Podbean after the
    # switch. Their totals are not added to the per-episode table, but their monthly
    # numbers are needed for the monthly takeaway, so every episode is fetched.
    pre_switch_eps = [ep for ep in episodes if ep not in post_switch_eps]
    print(f"  → plus {len(pre_switch_eps)} older episodes for monthly totals...")

    today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    result = {}
    for ep in post_switch_eps + pre_switch_eps:
        is_post  = ep in post_switch_eps
        title    = ep["title"].strip()
        pub_date = datetime.fromtimestamp(ep["publish_time"], tz=timezone.utc).strftime("%Y-%m-%d")
        params = urllib.parse.urlencode({
            "access_token": token,
            "episode_id":   ep["id"],
            # Older episodes only need post-switch months (asking for years back returns HTTP 400)
            "start":        pub_date if is_post else "2026-03-01",
            "end":          today,
            "period":       "m",
        })
        try:
            raw = podbean_get_with_retry(f"https://api.podbean.com/v1/podcastStats/stats?{params}")
            monthly = json.loads(raw)
            monthly = {k: v for k, v in monthly.items() if isinstance(v, (int, float))}
            total = sum(monthly.values())
        except Exception as e:
            print(f"  ⚠ Stats fetch failed for '{title}': {e}")
            total, monthly = None, None
        result[title] = {
            "podbean_downloads": total,
            "podbean_date":      pub_date,
            "podbean_monthly":   monthly,   # {"2026-10": 1883, ...} or None
            "post_switch":       is_post,
        }

    print(f"  → {sum(1 for r in result.values() if r['post_switch'] and r['podbean_downloads'] is not None)} post-switch Podbean episodes with stats parsed")
    return result

def parse_podbean_csv_text(csv_text):
    """
    Parse Podbean CSV content (as string) into a title → downloads dict.
    Handles flexible column naming across different Podbean report formats.
    """
    result = {}
    reader = csv.DictReader(io.StringIO(csv_text))
    cols = reader.fieldnames or []
    print(f"  → Podbean CSV columns: {cols}")

    title_col = next((c for c in cols if "title" in c.lower()), None)
    dl_col    = next((c for c in cols if "download" in c.lower() or "play" in c.lower()), None)
    date_col  = next((c for c in cols if "date" in c.lower() or "publish" in c.lower()), None)

    if not title_col or not dl_col:
        print(f"  ⚠ Cannot identify required columns in Podbean CSV.")
        print(f"     Expected columns with 'title' and 'download' or 'play'. Found: {cols}")
        return result

    for row in reader:
        title = row[title_col].strip()
        try:
            downloads = int(str(row[dl_col]).replace(",", "").strip())
        except (ValueError, TypeError):
            downloads = 0
        result[title] = {
            "podbean_downloads": downloads,
            "podbean_date": parse_date(row.get(date_col, "")) if date_col else "",
        }

    print(f"  → {len(result)} Podbean episodes parsed")
    return result

def load_podbean_csv_file(filepath):
    """Load Podbean data from a local CSV file (manual override / fallback)."""
    print(f"Loading Podbean CSV from file: {filepath}")
    with open(filepath, newline="", encoding="utf-8-sig") as f:
        return parse_podbean_csv_text(f.read())

# ── MERGE ─────────────────────────────────────────────────────────────────────
def merge(buzzsprout_data, podbean_data):
    """
    Combine Buzzsprout and Podbean data per episode.

    Pre-switch (before 2026-03-23):
      - buzzsprout_plays = external + app combined
      - app_dl and external_dl are None (split not available from API)

    Post-switch (2026-03-23 and after):
      - buzzsprout_plays = app-only
      - podbean_downloads = external-only (Apple Podcasts, Spotify, etc.)
      - total_plays = app + external (when both available)
    """
    all_titles = set(buzzsprout_data.keys()) | {
        t for t, pb in podbean_data.items() if pb.get("post_switch", True)
    }
    merged = []

    for title in all_titles:
        bz = buzzsprout_data.get(title, {})
        pb = podbean_data.get(title, {})

        date           = bz.get("date") or pb.get("podbean_date") or ""
        is_post_switch = date >= PLATFORM_SWITCH_DATE if date else False
        bz_plays       = bz.get("buzzsprout_plays", 0)
        pb_downloads   = pb.get("podbean_downloads") or None

        if is_post_switch:
            app_dl      = bz_plays
            external_dl = pb_downloads
            # Only sum when both values are available
            total = (app_dl + external_dl) if external_dl is not None else None
        else:
            app_dl      = None
            external_dl = None
            total       = bz_plays  # combined, no split

        merged.append({
            "title":              title,
            "date":               date,
            "duration_sec":       bz.get("duration_sec"),
            "buzzsprout_id":      bz.get("buzzsprout_id"),
            "is_post_switch":     is_post_switch,
            "buzzsprout_plays":   bz_plays,   # app-only post-switch, combined pre-switch
            "app_dl":             app_dl,     # post-switch only
            "external_dl":        external_dl,# post-switch only (from Podbean)
            "total_plays":        total,      # combined when available, combined pre-switch
            "podbean_downloads":  pb_downloads,
            "external_monthly":   pb.get("podbean_monthly"),  # Podbean listens per month, all episodes
        })

    merged.sort(key=lambda x: x["date"] or "", reverse=True)
    return merged

# ── MONTHLY LISTENS ───────────────────────────────────────────────────────────
# Podbean reports external listens per calendar month directly. Buzzsprout only
# reports running totals, so each run saves a snapshot of every episode's app
# count; app listens in a month = growth between snapshots (interpolated to the
# month boundary when it falls between two weekly snapshots).
SNAPSHOT_PATH = OUTPUT_DIR / "podcast_snapshots.json"
MONTHLY_FROM  = "2026-04"   # first full month after the platform switch

def _ts(iso):
    return datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()

def update_snapshots(episodes, taken_at):
    snaps = []
    if SNAPSHOT_PATH.exists():
        snaps = json.loads(SNAPSHOT_PATH.read_text()).get("snapshots", [])
    day = taken_at[:10]
    snaps = [s for s in snaps if s["taken_at"][:10] != day]   # one per day; latest wins
    snaps.append({
        "taken_at": taken_at,
        "app": {str(e["buzzsprout_id"]): e["buzzsprout_plays"] for e in episodes if e.get("buzzsprout_id")},
    })
    snaps.sort(key=lambda s: s["taken_at"])
    SNAPSHOT_PATH.write_text(json.dumps({"snapshots": snaps}))
    return snaps

MAX_INTERP_GAP = 10 * 86400   # don't interpolate across snapshot gaps longer than this

def _snap_boundary(snaps, t):
    """Month boundary to measure at. Inside a long snapshot gap, use the nearer real snapshot."""
    times = [_ts(s["taken_at"]) for s in snaps]
    for t0, t1 in zip(times, times[1:]):
        if t0 < t < t1 and t1 - t0 > MAX_INTERP_GAP:
            return t0 if t - t0 < t1 - t else t1
    return t

def _app_at(snaps, ep_id, t, pub_t=None):
    """Interpolated running app total for one episode at time t, or None if t is outside the snapshots.
    An episode has 0 listens before its release, so growth starts at the release time."""
    if pub_t is not None and t <= pub_t:
        return 0
    pts = [(_ts(s["taken_at"]), s["app"].get(ep_id, 0)) for s in snaps]
    if pub_t is not None:
        pts = [(pub_t, 0)] + [pt for pt in pts if pt[0] > pub_t]
    if not pts or t < pts[0][0] or t > pts[-1][0]:
        return None
    for (t0, v0), (t1, v1) in zip(pts, pts[1:]):
        if t0 <= t <= t1:
            return v0 + (v1 - v0) * ((t - t0) / (t1 - t0) if t1 > t0 else 0)
    return pts[-1][1]

def compute_monthly(episodes, snaps, podbean_data, now):
    months, y, m = [], int(MONTHLY_FROM[:4]), int(MONTHLY_FROM[5:])
    while (y, m) <= (now.year, now.month):
        months.append(f"{y:04d}-{m:02d}")
        y, m = (y + 1, 1) if m == 12 else (y, m + 1)

    first_t = _ts(snaps[0]["taken_at"]) if snaps else None
    last_t  = _ts(snaps[-1]["taken_at"]) if snaps else None
    out = []
    for mo in months:
        y, m = int(mo[:4]), int(mo[5:])
        start = datetime(y, m, 1, tzinfo=timezone.utc)
        end   = datetime(y + (m == 12), 1 if m == 12 else m + 1, 1, tzinfo=timezone.utc)
        complete = now >= end

        # External: every Podbean episode, exact calendar month
        external = sum((pb.get("podbean_monthly") or {}).get(mo, 0) for pb in podbean_data.values())

        # App: snapshot growth over the part of the month the snapshots cover
        app, win = None, None
        if snaps:
            a = _snap_boundary(snaps, max(start.timestamp(), first_t))
            b = _snap_boundary(snaps, min(end.timestamp(), last_t))
            if b > a:
                win = (a, b)
                app = 0
        per_ep = []
        for e in episodes:
            ext = (e.get("external_monthly") or {}).get(mo, 0)
            ap  = 0
            if win and e.get("buzzsprout_id"):
                eid = str(e["buzzsprout_id"])
                pub_t = _ts(e["date"] + "T00:00:00+00:00") if e.get("date") else None
                v0, v1 = _app_at(snaps, eid, win[0], pub_t), _app_at(snaps, eid, win[1], pub_t)
                ap = max(0, round((v1 or 0) - (v0 or 0)))
                app += ap
            if ext or ap:
                per_ep.append({"title": e["title"], "date": e["date"], "app": ap,
                               "external": ext, "total": ap + ext})
        per_ep.sort(key=lambda r: r["total"], reverse=True)
        new_eps = [r for r in per_ep if (r["date"] or "").startswith(mo)]
        out.append({
            "month":          mo,
            "complete":       complete,
            "external":       external,
            "app":            app,
            # App window: which dates the app number actually covers
            "app_from":       datetime.fromtimestamp(win[0], timezone.utc).strftime("%Y-%m-%d") if win else None,
            "app_to":         datetime.fromtimestamp(win[1], timezone.utc).strftime("%Y-%m-%d") if win else None,
            # True when snapshots cover the whole month (or, for the current month, all of it so far)
            "app_full_month": bool(win) and win[0] <= start.timestamp() + 86400
                              and (win[1] >= end.timestamp() - 86400 or not complete),
            "episodes_released": sum(1 for e in episodes if (e["date"] or "").startswith(mo)),
            "new_episode_listens": {"app": sum(r["app"] for r in new_eps), "external": sum(r["external"] for r in new_eps)},
            "top_episodes":   per_ep[:5],
        })
    return out

# ── OUTPUT ────────────────────────────────────────────────────────────────────
def write_output(episodes, podbean_data=None):
    OUTPUT_DIR.mkdir(exist_ok=True)
    now = datetime.now(timezone.utc)
    snaps = update_snapshots(episodes, now.isoformat())
    monthly = compute_monthly(episodes, snaps, podbean_data or {}, now)
    out_path = OUTPUT_DIR / "podcast_stats.json"

    eps_2026    = [e for e in episodes if (e["date"] or "").startswith("2026")]
    post_switch = [e for e in eps_2026 if e["is_post_switch"]]
    pre_switch  = [e for e in eps_2026 if not e["is_post_switch"]]
    has_podbean = any(e["podbean_downloads"] for e in post_switch)

    payload = {
        "generated_at":         now.isoformat(),
        "platform_switch_date": PLATFORM_SWITCH_DATE,
        "podbean_connected":    has_podbean,
        "summary": {
            "total_episodes":       len(episodes),
            "episodes_2026":        len(eps_2026),
            "buzzsprout_plays_2026": sum(e["buzzsprout_plays"] for e in eps_2026),
        },
        "monthly":  monthly,
        "episodes": episodes,
    }

    with open(out_path, "w") as f:
        json.dump(payload, f, indent=2)

    print(f"\n✓ Written to {out_path}")
    print(f"  Total episodes:        {len(episodes)}")
    print(f"  2026 episodes:         {len(eps_2026)}")
    print(f"  Pre-switch:            {len(pre_switch)}")
    print(f"  Post-switch:           {len(post_switch)}")
    print(f"  Podbean data present:  {'yes' if has_podbean else 'no — external_dl will show pending'}")
    print(f"  Buzzsprout 2026 plays: {payload['summary']['buzzsprout_plays_2026']:,}")

# ── MAIN ──────────────────────────────────────────────────────────────────────
def main():
    parser = argparse.ArgumentParser(description="Fetch SWAPA podcast analytics")
    parser.add_argument(
        "--podbean", metavar="FILE",
        help="Path to a local Podbean CSV export (fallback if API unavailable)"
    )
    args = parser.parse_args()

    # Step 1: Always fetch Buzzsprout
    buzzsprout_data = fetch_buzzsprout()

    # Step 2: Fetch Podbean data
    if args.podbean:
        # Manual override: use local CSV file
        podbean_data = load_podbean_csv_file(args.podbean)
    else:
        # Normal path: authenticate and pull via API
        try:
            token        = get_podbean_token()
            podbean_data = fetch_podbean_downloads(token)
        except Exception as e:
            print(f"  ⚠ Podbean API error: {e}")
            print("    External downloads will be null this run.")
            podbean_data = {}

    if not podbean_data:
        print("  (No Podbean data — post-switch external downloads will show as pending)")

    # Step 3: Merge and write
    merged = merge(buzzsprout_data, podbean_data)
    write_output(merged, podbean_data)

if __name__ == "__main__":
    main()
