"""Get the transcript of every new upload from the YouTube channels you follow.

    pip install httpx
    export YTT_API_KEY=tk_live_...
    python upload_monitor.py @TED @veritasium --out transcripts
    python upload_monitor.py @TED --slack https://hooks.slack.com/services/...

Run it on a schedule (cron, Task Scheduler, GitHub Actions). Each run:
  1. Checks each channel's latest 15 uploads (free).
  2. On a channel's first run, records what's already there and fetches
     nothing, so you only get videos published from now on (--backfill
     fetches those 15 too).
  3. Fetches the transcript of each new video (1 credit per transcript) and
     saves it as OUT/VIDEO_ID.md.
  4. Sends it to Slack, Discord or any webhook you pass.

New videos often have no captions for a while after upload. A 404 isn't
charged, so the video waits and is tried again on the next run, until
captions appear or --give-up-hours passes. Everything is kept in a small
state file, so a video is never delivered twice and a failed delivery is
retried from the saved file without paying for the transcript again.
"""

import argparse
import json
import os
import sys
import time
from pathlib import Path

import httpx

API = os.environ.get("YTT_API_BASE", "https://youtubetranscriptapi.com/api/v2")
RETRYABLE = {408, 429, 500, 503}
FEED_SIZE = 15


class OutOfCredits(Exception):
    pass


def call(client: httpx.Client, path: str, params: dict, tries: int = 4) -> httpx.Response:
    """GET with retries on transient errors, honouring Retry-After."""
    for attempt in range(tries):
        resp = client.get(f"{API}{path}", params=params)
        if resp.status_code == 402:
            raise OutOfCredits(resp.json().get("detail", "out of credits"))
        if resp.status_code not in RETRYABLE:
            return resp
        time.sleep(min(float(resp.headers.get("Retry-After", 2 ** attempt)), 60))
    return resp


def load_state(path: Path) -> dict:
    if path.exists():
        return json.loads(path.read_text(encoding="utf-8"))
    return {"channels": {}, "videos": {}}


def save_state(path: Path, state: dict):
    tmp = path.with_suffix(".tmp")
    tmp.write_text(json.dumps(state, indent=1, ensure_ascii=False), encoding="utf-8")
    tmp.replace(path)  # never leave a half-written state file behind


def clock(seconds: float) -> str:
    m, s = divmod(int(seconds), 60)
    h, m = divmod(m, 60)
    return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"


def write_markdown(out: Path, video: dict, data: dict) -> Path:
    lines = [f"# {video['title']}", "", f"https://www.youtube.com/watch?v={video['id']}", "",
             f"Channel: {video['channel']}  ", f"Published: {video.get('published') or 'unknown'}  ",
             f"Caption language: {data['language']}", ""]
    for seg in data["transcript"]:
        lines.append(f"[{clock(seg.get('start', 0))}] {seg['text']}")
    path = out / f"{video['id']}.md"
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")
    return path


def plain_text(path: Path) -> str:
    """Transcript text from a saved file, without the header or timestamps."""
    body = path.read_text(encoding="utf-8").split("\n")[8:]
    return " ".join(line.split("] ", 1)[-1] for line in body if line.strip())


def deliver(client: httpx.Client, a, video: dict, path: Path) -> bool:
    """Send to every target not yet reached. Returns True once all have it."""
    text = plain_text(path)
    link = f"https://www.youtube.com/watch?v={video['id']}"
    excerpt = text[:500] + ("..." if len(text) > 500 else "")
    ok = True
    targets = []
    if a.slack:
        targets.append(("slack", a.slack, {"text": f"*New from {video['channel']}:* <{link}|{video['title']}>\n>{excerpt}"}))
    if a.discord:
        msg = f"**New from {video['channel']}:** {video['title']}\n{link}\n> {excerpt}"
        targets.append(("discord", a.discord, {"content": msg[:2000]}))  # Discord's limit
    if a.webhook:
        meta = {k: video[k] for k in ("id", "title", "channel", "published")}
        targets.append(("webhook", a.webhook, {**meta, "url": link, "transcript": text}))
    sent = video.setdefault("sent", [])
    for name, url, payload in targets:
        if name in sent:
            continue  # already delivered there on an earlier run
        try:
            r = client.post(url, json=payload, timeout=30)
            r.raise_for_status()
            sent.append(name)
        except httpx.HTTPError as e:
            print(f"  {name} delivery failed ({type(e).__name__}); will retry next run", file=sys.stderr)
            ok = False
    return ok


def main() -> int:
    p = argparse.ArgumentParser()
    p.add_argument("channels", nargs="*", help="@handles, channel URLs or UC... IDs")
    p.add_argument("--channels-file", help="text file with one channel per line")
    p.add_argument("--out", default="transcripts", help="folder for VIDEO_ID.md files")
    p.add_argument("--state", default="monitor_state.json")
    p.add_argument("--language", default="en")
    p.add_argument("--slack", help="Slack incoming webhook URL")
    p.add_argument("--discord", help="Discord webhook URL")
    p.add_argument("--webhook", help="any URL that accepts a JSON POST with the full transcript")
    p.add_argument("--give-up-hours", type=float, default=48, help="stop waiting for captions after this long")
    p.add_argument("--backfill", action="store_true", help="on a channel's first run, fetch its latest 15 too")
    a = p.parse_args()
    sys.stdout.reconfigure(encoding="utf-8", line_buffering=True)  # keeps cron logs in order

    channels = list(a.channels)
    if a.channels_file:
        channels += [l.strip() for l in Path(a.channels_file).read_text(encoding="utf-8").splitlines()
                     if l.strip() and not l.startswith("#")]
    key = os.environ.get("YTT_API_KEY")
    if not key or not channels:
        print("Set YTT_API_KEY and pass at least one channel.", file=sys.stderr)
        return 1

    state_path, out = Path(a.state), Path(a.out)
    out.mkdir(parents=True, exist_ok=True)
    state = load_state(state_path)
    videos = state["videos"]
    now = time.time()
    rc = 0

    with httpx.Client(headers={"Authorization": f"Bearer {key}"}, timeout=60) as client:
        # 1. Find new uploads (free).
        for channel in channels:
            resp = call(client, "/youtube/channel/latest", {"channel": channel})
            if resp.status_code != 200:
                print(f"{channel}: HTTP {resp.status_code} checking uploads, skipped this run", file=sys.stderr)
                continue
            body = resp.json()
            name = body.get("channel_title") or channel
            first_run = channel not in state["channels"]
            fresh = [v for v in body["results"] if v["videoId"] not in videos]
            for v in fresh:
                status = "old" if first_run and not a.backfill else "waiting"
                videos[v["videoId"]] = {"id": v["videoId"], "title": v.get("title") or v["videoId"], "channel": name,
                                        "published": v.get("publishedAt"), "status": status, "first_seen": now, "tries": 0}
            if first_run:
                print(f"{name}: first run, {len(fresh)} existing uploads recorded"
                      + (" and queued (--backfill)." if a.backfill else ", nothing fetched."))
            elif len(fresh) >= FEED_SIZE:
                print(f"{name}: all {FEED_SIZE} latest uploads are new, so some may have been missed. Run more often.")
            elif fresh:
                print(f"{name}: {len(fresh)} new upload(s).")
            state["channels"][channel] = {"name": name, "checked_at": now}
        save_state(state_path, state)

        # 2. Fetch transcripts for waiting videos (1 credit each; 404 is free).
        counts = {"delivered": 0, "gave_up": 0}
        try:
            for v in videos.values():
                if v["status"] == "waiting":
                    resp = call(client, "/youtube/transcript", {"video_url": v["id"], "language": a.language})
                    v["tries"] += 1
                    if resp.status_code == 200:
                        write_markdown(out, v, resp.json())
                        v["status"] = "saved"
                    elif resp.status_code == 404 and (now - v["first_seen"]) / 3600 >= a.give_up_hours:
                        v["status"] = "gave_up"
                        counts["gave_up"] += 1
                        print(f"  {v['title']}: still no captions after {a.give_up_hours:g} h, giving up.")
                    elif resp.status_code != 404:
                        print(f"  {v['id']}: HTTP {resp.status_code}, will retry next run", file=sys.stderr)
                    save_state(state_path, state)
                # 3. Deliver saved transcripts (also retries earlier failed deliveries).
                if v["status"] == "saved":
                    if deliver(client, a, v, out / f"{v['id']}.md"):
                        v["status"] = "delivered"
                        counts["delivered"] += 1
                        print(f"  saved {out / (v['id'] + '.md')}: {v['title']}")
                    save_state(state_path, state)
        except OutOfCredits as e:
            print(f"Stopped: {e}. Waiting videos are kept for the next run.", file=sys.stderr)
            rc = 2
        save_state(state_path, state)

    waiting = sum(v["status"] in ("waiting", "saved") for v in videos.values())
    print(f"Delivered {counts['delivered']}, gave up on {counts['gave_up']}, {waiting} still waiting.")
    return rc


if __name__ == "__main__":
    sys.exit(main())
