"""Sends a finished video to a Postline channel through the HTTP API - for calling
at the end of any project's render step. Standard library only; copy this file
anywhere.

Needs an API key from Postline → Settings → API keys:
    export POSTLINE_KEY=pl_...

    python3 postline_post.py "AI GOLA" final/video.mp4                 # queue it
    python3 postline_post.py "AI GOLA" video.mp4 --now --wait          # post now, print the links
    python3 postline_post.py "AI GOLA" video.mp4 --title "Hi" --caption "#tag" --platforms youtube,facebook
    python3 postline_post.py --channels                                # list channel names

Title and caption: --title/--caption, else post.json / <video>.txt / title.md next
to the video (same formats as Postline's Upload tab), else the file name.

From Python:
    from postline_post import post
    post("AI GOLA", "final/video.mp4", title="...", caption="...", now=True)
"""
from __future__ import annotations

import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path

API = os.environ.get("POSTLINE_API", "https://adorable-leopard-825.convex.site/api/v1")


class PostlineError(Exception):
    pass


def _call(method: str, path: str, body: dict | None = None) -> dict:
    key = os.environ.get("POSTLINE_KEY")
    if not key:
        raise PostlineError("Set POSTLINE_KEY - make a key in Postline → Settings → API keys")
    req = urllib.request.Request(
        f"{API}{path}",
        method=method,
        data=json.dumps(body).encode() if body is not None else None,
        headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
    )
    try:
        with urllib.request.urlopen(req, timeout=60) as r:
            return json.load(r)
    except urllib.error.HTTPError as e:
        try:
            msg = json.load(e).get("error", e.reason)
        except Exception:
            msg = e.reason
        raise PostlineError(f"{e.code}: {msg}") from None


def _put(up: dict, path: Path) -> None:
    """Uploads the file to the bucket - big files in parts (up["parts"]), each through the relay."""
    for p in up.get("parts") or [{"url": up["uploadUrl"], "start": 0, "end": path.stat().st_size}]:
        _put_range(p["url"], path, p["start"], p["end"])


class _Range:
    """Reads bytes [start, end) of a file."""

    def __init__(self, f, start: int, end: int):
        f.seek(start)
        self.f, self.left = f, end - start

    def read(self, n: int = -1) -> bytes:
        b = self.f.read(self.left if n is None or n < 0 else min(n, self.left))
        self.left -= len(b)
        return b


def _put_range(url: str, path: Path, start: int, end: int, tries: int = 4) -> None:
    """Uploads bytes [start, end) of the file; slow or reset connections get retried."""
    for attempt in range(1, tries + 1):
        try:
            with path.open("rb") as raw:
                f = _Range(raw, start, end)
                req = urllib.request.Request(url, method="PUT", data=f, headers={"Content-Length": str(end - start), "Content-Type": "video/mp4", "User-Agent": "postline_post/1.0"})  # Cloudflare (relay) blocks "Python-urllib" (error 1010)
                with urllib.request.urlopen(req, timeout=1800):
                    return
        except (urllib.error.URLError, OSError) as e:
            if attempt == tries:
                raise PostlineError(f"Upload failed after {tries} tries: {e}") from None
            print(f"  upload failed ({e}) - trying again in {15 * attempt}s", file=sys.stderr)
            time.sleep(15 * attempt)


def _sheet(video: Path) -> dict:
    """Title/caption/tags from a sheet next to the video, like the Upload tab."""
    d = video.parent
    if d.name == "final":
        d = d.parent
    for p in (d / "post.json", video.with_suffix(".json")):
        if p.is_file():
            try:
                j = json.loads(p.read_text())
                j = j.get("post", j) if isinstance(j, dict) else {}
                return {
                    "title": str(j.get("title", "")),
                    "caption": str(j.get("caption") or j.get("social_caption") or j.get("description") or ""),
                    "tags": [str(t) for t in j.get("tags", [])] if isinstance(j.get("tags"), list) else [],
                }
            except ValueError:
                pass
    for p in (video.with_suffix(".txt"), d / "title.md"):
        if p.is_file():
            lines = p.read_text().strip().splitlines()
            if not lines:
                continue
            if p.suffix == ".md":
                title = re.sub(r"^#+\s*|^Title:\s*", "", lines[0]).strip()
            else:
                title = lines[0].strip()
            return {"title": title, "caption": "\n".join(lines[1:]).strip(), "tags": []}
    return {}


def channels() -> list[dict]:
    return _call("GET", "/channels")["channels"]


def status(video_id: str) -> dict:
    return _call("GET", f"/videos/{video_id}")


def post(
    channel: str,
    video: str | Path,
    title: str | None = None,
    caption: str | None = None,
    tags: list[str] | None = None,
    platforms: list[str] | None = None,
    now: bool = False,
) -> dict:
    """Uploads `video` and adds it to `channel` (name or id). Returns {id, status}."""
    path = Path(video)
    if not path.is_file():
        raise PostlineError(f"No file at {path}")
    sheet = _sheet(path)
    title = title or sheet.get("title") or re.sub(r"[-_]+", " ", path.stem).strip()
    up = _call("POST", "/uploads", {"channel": channel, "fileName": path.name, "size": path.stat().st_size})
    _put(up, path)
    body: dict = {
        "channel": channel,
        "fileKey": up["fileKey"],
        "fileName": path.name,
        "title": title,
        "caption": caption if caption is not None else sheet.get("caption", ""),
        "tags": tags if tags is not None else sheet.get("tags", []),
        "postNow": now,
    }
    if platforms:
        body["platforms"] = platforms
    return _call("POST", "/videos", body)


def wait(video_id: str, every: int = 15, timeout: int = 1800) -> dict:
    """Polls until every platform is done (or failed)."""
    end = time.time() + timeout
    while True:
        s = status(video_id)
        if s["status"] not in ("queued", "posting") or time.time() > end:
            return s
        time.sleep(every)


def main() -> None:
    ap = argparse.ArgumentParser(description="Send a video to a Postline channel.")
    ap.add_argument("channel", nargs="?", help="channel name (or id)")
    ap.add_argument("video", nargs="?", help="the video file")
    ap.add_argument("--title")
    ap.add_argument("--caption")
    ap.add_argument("--tags", help="comma separated")
    ap.add_argument("--platforms", help="comma separated: youtube,facebook,instagram (default: all connected)")
    ap.add_argument("--now", action="store_true", help="post right away instead of waiting for the next time slot")
    ap.add_argument("--wait", action="store_true", help="with --now: wait and print each platform's link")
    ap.add_argument("--channels", action="store_true", help="list the workspace's channels")
    a = ap.parse_args()
    try:
        if a.channels:
            for c in channels():
                plats = ", ".join(f"{p['platform']} ({p['account']})" for p in c["platforms"]) or "no platforms"
                print(f"{c['name']}  [{c['id']}]  {plats}")
            return
        if not a.channel or not a.video:
            ap.error("give a channel and a video, or --channels")
        r = post(
            a.channel,
            a.video,
            title=a.title,
            caption=a.caption,
            tags=[t.strip() for t in a.tags.split(",")] if a.tags else None,
            platforms=[p.strip() for p in a.platforms.split(",")] if a.platforms else None,
            now=a.now,
        )
        print(f"{r['status']}: {r['id']}")
        if a.wait and r["status"] == "posting":
            s = wait(r["id"])
            for p in s["posts"]:
                print(f"  {p['platform']}: {p['status']}  {p['url'] or p['error'] or ''}")
    except PostlineError as e:
        sys.exit(f"Postline: {e}")


if __name__ == "__main__":
    main()
