#!/usr/bin/env python3
"""holina - the Holina Factory command line.

Download https://holinafactory.app/site/holina.py and run it with Python 3.8 or
newer. There is nothing else to install.

  python holina.py login                 sign in with a 6-digit code emailed to you
  python holina.py usage                 your plan and what is left this month
  python holina.py engines               the engines you can render on right now
  python holina.py generate --engine h3 --prompt "a slow push-in on ..." --wait
  python holina.py status JOB            where a render is
  python holina.py outputs               your newest clips and stills
  python holina.py download ID           save one to this folder
  python holina.py verdicts              what you kept and rejected in Review
  python holina.py ship ID               the channels, time and caption a post would use
  python holina.py logout

Nothing posts without `ship ... --yes`. Your sign-in lasts 7 days and is kept in
~/.holina/session.json (HOLINA_SESSION_FILE moves it). HOLINA_BASE points at
another Factory (default https://holinafactory.app). Staff can replace the
sign-in with HOLINA_CF_ID + HOLINA_CF_SECRET (a Cloudflare Access service
token) or HOLINA_BEARER.
"""
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request

VERSION = "1.0.0"
BASE = (os.environ.get("HOLINA_BASE") or "https://holinafactory.app").rstrip("/")
SESSION = (os.environ.get("HOLINA_SESSION_FILE")
           or os.path.join(os.path.expanduser("~"), ".holina", "session.json"))
UA = "holina-cli/%s (+https://holinafactory.app)" % VERSION
COOKIE = "hf_session"
TRIAL_URL = "https://holinafactory.app/#pricing"


class Fail(Exception):
    """A message for the person at the keyboard; main() prints it and exits 1."""


# ---------------------------------------------------------------- the sign-in

def load_session():
    """The saved sign-in, or None when there is none or it has expired. The
    token carries its own expiry: "<unix time>|<email>|<signature>"."""
    try:
        with open(SESSION, encoding="utf-8") as f:
            saved = json.load(f)
        exp = int(str(saved.get("token", "")).split("|", 1)[0])
    except (OSError, ValueError, AttributeError):
        return None
    if exp < time.time():
        return None
    saved["expires"] = exp
    return saved


def save_session(email, token):
    folder = os.path.dirname(SESSION)
    if folder:
        os.makedirs(folder, exist_ok=True)
    with open(SESSION, "w", encoding="utf-8") as f:
        json.dump({"email": email, "token": token}, f)
    try:
        os.chmod(SESSION, 0o600)
    except OSError:
        pass


def auth_headers():
    cf_id = (os.environ.get("HOLINA_CF_ID") or "").strip()
    cf_secret = (os.environ.get("HOLINA_CF_SECRET") or "").strip()
    bearer = (os.environ.get("HOLINA_BEARER") or "").strip()
    if cf_id and cf_secret:
        return {"CF-Access-Client-Id": cf_id, "CF-Access-Client-Secret": cf_secret}
    if bearer:
        return {"Authorization": "Bearer " + bearer}
    saved = load_session()
    if not saved:
        raise Fail("Not signed in. Run: python holina.py login")
    return {"Cookie": "%s=%s" % (COOKIE, saved["token"])}


# ---------------------------------------------------------------- HTTP

def request(method, path, body=None, auth=True, headers=None, timeout=90):
    """(status, parsed body, response headers). Never raises on an HTTP status."""
    h = {"User-Agent": UA, "Accept": "application/json"}
    if auth:
        h.update(auth_headers())
    h.update(headers or {})
    data = None
    if body is not None:
        data = json.dumps(body).encode("utf-8")
        h["Content-Type"] = "application/json"
    req = urllib.request.Request(BASE + path, data=data, headers=h, method=method)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            return r.status, parse(r.read()), r.headers
    except urllib.error.HTTPError as e:
        return e.code, parse(e.read()), e.headers
    except (urllib.error.URLError, OSError) as e:
        raise Fail("Could not reach %s (%s)" % (BASE, getattr(e, "reason", e)))


def parse(raw):
    try:
        return json.loads(raw.decode("utf-8"))
    except (UnicodeDecodeError, ValueError):
        text = raw.decode("utf-8", "replace").strip()
        return {"error": text[:300] or "empty answer"}


def error_of(body):
    return body.get("error") if isinstance(body, dict) else None


def need(result, ok=(200,)):
    """The body of a successful answer, or a Fail that says what went wrong."""
    code, body, _ = result
    if code in ok:
        return body
    if code == 401:
        raise Fail("Not signed in, or your sign-in has expired. Run: python holina.py login")
    if code == 402:
        raise Fail(error_of(body) or "That needs a plan. Start your 14-day trial at " + TRIAL_URL)
    raise Fail("%s (HTTP %s)" % (error_of(body) or "The Factory refused that", code))


def show(data):
    print(json.dumps(data, indent=2))


def review_url(cid):
    return "%s/app/review?ids=%s" % (BASE, cid)


# ---------------------------------------------------------------- commands

def cmd_login(a):
    email = (a.email or input("Email: ")).strip()
    code, body, _ = request("POST", "/site/auth/otp", {"email": email}, auth=False)
    if code != 200:
        raise Fail(error_of(body) or "Could not send a code (HTTP %s)" % code)
    print("We emailed a 6-digit code to %s. It works for 10 minutes." % email)
    for _ in range(3):
        otp = input("Code: ").strip().replace(" ", "")
        code, body, hdrs = request("POST", "/site/auth/verify", {"email": email, "code": otp},
                                   auth=False)
        if code == 200:
            token = None
            for cookie in hdrs.get_all("Set-Cookie") or []:
                if cookie.startswith(COOKIE + "="):
                    token = cookie.split(";", 1)[0].split("=", 1)[1]
            if not token or token.count("|") != 2:
                raise Fail("The code was accepted but no sign-in came back. Try again.")
            save_session(token.split("|")[1], token)
            print("Signed in as %s. The sign-in lasts 7 days on this computer."
                  % token.split("|")[1])
            return
        print(error_of(body) or "That code did not work (HTTP %s)." % code)
    raise Fail("Three codes did not work. Run login again for a new one.")


def cmd_logout(_a):
    try:
        os.remove(SESSION)
    except OSError:
        pass
    print("Signed out. The sign-in on this computer is deleted.")


def cmd_whoami(a):
    saved = load_session()
    u = need(request("GET", "/api/usage"))
    if a.json:
        return show({"email": saved and saved["email"], "usage": u})
    who = saved["email"] if saved else "a staff token"
    until = (" Sign-in good until %s." % time.strftime("%Y-%m-%d %H:%M",
                                                        time.localtime(saved["expires"]))
             if saved else "")
    print("%s, %s plan.%s" % (who, (u.get("pack") or {}).get("name", "unknown"), until))


def cmd_usage(a):
    u = need(request("GET", "/api/usage"))
    if a.json:
        return show(u)
    pack, left = u.get("pack") or {}, u.get("left") or {}
    if not pack.get("plan"):
        print("No plan yet. Start your 14-day trial at " + TRIAL_URL)
        return
    print("%s plan, resets %s" % (pack.get("name"), u.get("reset") or "monthly"))
    print("  clips   %s of %s left" % (left.get("clips"), pack.get("clips")))
    print("  stills  %s of %s left" % (left.get("stills"), pack.get("stills")))
    print("  hooks   %s of %s left (inside the clip count)" % (left.get("hooks"), pack.get("hooks")))
    if pack.get("plan") == "trial":
        print("  Trial clips are up to 8 s, delivered at 720p with a watermark.")


def cmd_engines(a):
    eng = need(request("GET", "/api/engines")).get("engines") or {}
    avail = need(request("GET", "/api/generate/availability")).get("engines") or {}
    if a.json:
        return show({"engines": eng, "availability": avail})
    for name, e in eng.items():
        av = avail.get(name) or {}
        state = "ready" if av.get("ok") else "paused: %s" % (av.get("reason") or "unavailable")
        cost = ""
        if e.get("units"):
            cost = ", %s %s" % (e.get("units"), "unit" if e.get("units") == 1 else "units")
        print("%-11s %-6s %s%s" % (name, e.get("media", ""), state, cost))
        if e.get("note"):
            print("            " + e["note"])


def media_ready(cid):
    """True once a clip or still exists on your account. One byte is enough."""
    for path in ("/api/clip/%s.mp4" % cid, "/api/still/%s.png" % cid):
        code, _, _ = request("GET", path, headers={"Range": "bytes=0-0"})
        if code in (200, 206):
            return True
    return False


def wait_for(job, minutes):
    end = time.time() + minutes * 60
    ids, said = None, None
    while time.time() < end:
        st = need(request("GET", "/api/generate/job/%s" % job))
        state = st.get("state")
        if state == "error":
            raise Fail("The render failed: %s" % (st.get("error") or "no reason given"))
        if state == "unknown":
            raise Fail("The Factory no longer tracks job %s. Your output still appears in: "
                       "python holina.py outputs" % job)
        if state == "done":
            ids = st.get("ids") or []
            break
        if state != said:
            print("  %s" % state)
            said = state
        time.sleep(5)
    if not ids:
        raise Fail("Job %s had not started after %s minutes. Check: python holina.py status %s"
                   % (job, minutes, job))
    print("Rendering %s. This can take a while; Ctrl+C stops waiting, not the render."
          % ", ".join(ids))
    pending = list(ids)
    while pending and time.time() < end:
        for cid in list(pending):
            if media_ready(cid):
                pending.remove(cid)
                print("Ready: %s  %s" % (cid, review_url(cid)))
        if pending:
            time.sleep(15)
    if pending:
        raise Fail("Still rendering after %s minutes: %s. Check later: python holina.py outputs"
                   % (minutes, ", ".join(pending)))


def cmd_generate(a):
    body = {"engine": a.engine, "prompt": a.prompt, "count": a.count}
    for key in ("aspect", "duration", "resolution", "seed", "image"):
        if getattr(a, key) is not None:
            body[key] = getattr(a, key)
    r = need(request("POST", "/api/generate", body), ok=(200, 202))
    if a.json and not a.wait:
        return show(r)
    job = r.get("job")
    print("Queued job %s on %s." % (job, r.get("engine") or a.engine))
    if a.wait:
        wait_for(job, a.timeout)
    else:
        print("Follow it: python holina.py status %s" % job)


def cmd_status(a):
    st = need(request("GET", "/api/generate/job/%s" % a.job))
    if a.json:
        return show(st)
    state = st.get("state")
    if state == "done":
        for cid in st.get("ids") or []:
            print("%s  %s  %s" % (cid, "ready" if media_ready(cid) else "rendering",
                                  review_url(cid)))
    elif state == "error":
        print("failed: %s" % (st.get("error") or "no reason given"))
    elif state == "unknown":
        print("unknown: the Factory no longer tracks this job. Try: python holina.py outputs")
    else:
        q = st.get("queue") or {}
        print("%s%s" % (state, (", %s ahead in the queue" % q["pending"]) if "pending" in q else ""))


def cmd_outputs(a):
    q = need(request("GET", "/api/review/queue"))
    items = (q.get("clips") if isinstance(q, dict) else q) or []
    items = items[:a.limit]
    if a.json:
        return show(items)
    if not items:
        print("No outputs yet. Make one: python holina.py generate --engine h3 --prompt \"...\"")
        return
    for it in items:
        print("%-24s %-6s %s  %s" % (it.get("id"), it.get("media", ""),
                                     (it.get("mtime") or "")[:16], review_url(it.get("id"))))


def cmd_download(a):
    preparing = False
    for path, ext in (("/api/clip/%s.mp4", ".mp4"), ("/api/still/%s.png", ".png")):
        # A trial copy, or a paid plan's 1080p copy, is made on first ask (503
        # until then): up to 15 minutes.
        for _ in range(90):
            req = urllib.request.Request(BASE + path % a.id, headers=dict(
                {"User-Agent": UA}, **auth_headers()))
            try:
                with urllib.request.urlopen(req, timeout=300) as r:
                    dest = a.output or (a.id + ext)
                    size = 0
                    with open(dest, "wb") as f:
                        while True:
                            chunk = r.read(1 << 20)
                            if not chunk:
                                break
                            f.write(chunk)
                            size += len(chunk)
                    print("Saved %s (%.1f MB)" % (dest, size / 1e6))
                    return
            except urllib.error.HTTPError as e:
                if e.code == 503:        # a trial or 1080p copy is being prepared
                    if not preparing:
                        print("Preparing your copy...")
                    preparing = True
                    time.sleep(10)
                    continue
                if e.code == 401:
                    raise Fail("Not signed in, or your sign-in has expired. "
                               "Run: python holina.py login")
                break                    # 404: try the other kind, then give up
            except (urllib.error.URLError, OSError) as e:
                raise Fail("Could not reach %s (%s)" % (BASE, getattr(e, "reason", e)))
        if preparing:
            raise Fail("Your copy of %s is still being prepared. Try again in a few minutes."
                       % a.id)
    raise Fail("No finished output %s on your account." % a.id)


def cmd_verdicts(a):
    v = need(request("GET", "/api/verdicts"))
    if a.json:
        return show(v)
    print("%s judged: %s kept, %s rejected" % (v.get("judged", 0), v.get("keep", 0),
                                              v.get("reject", 0)))
    for cid, row in sorted((v.get("verdicts") or {}).items(), key=lambda kv: kv[1].get("ts", "")):
        note = (" - " + row["note"]) if row.get("note") else ""
        print("  %-24s %s%s" % (cid, row.get("verdict"), note))


def cmd_ship(a):
    pv = need(request("GET", "/api/ship/preview/%s" % a.id))
    if not a.yes:
        if a.json:
            return show(pv)
        chans = pv.get("channels") or []
        print("%s (%s)" % (pv.get("id"), pv.get("media")))
        print("  your channels: %s" % (", ".join("%s (%s)" % (c["key"], c.get("name", c["key"]))
                                                  for c in chans)
                                      or "none connected - connect them in the app's Connectors page"))
        print("  next slot: %s" % pv.get("when"))
        print("  caption: %s" % (pv.get("caption") or "(none yet)"))
        for w in pv.get("warnings") or []:
            print("  warning: %s" % w)
        if chans:
            print("To schedule it: python holina.py ship %s --channels %s --when %s --yes"
                  % (pv.get("id"), chans[0]["key"], pv.get("when")))
        return
    if not a.channels or not a.when:
        raise Fail("Scheduling needs --channels and --when as well as --yes.")
    body = {"id": a.id, "when": a.when,
            "channels": [c.strip() for c in a.channels.split(",") if c.strip()]}
    if a.caption is not None:
        body["caption"] = a.caption
    r = need(request("POST", "/api/ship", body), ok=(200, 202))
    if a.json:
        return show(r)
    print("Scheduled %s for %s on %s." % (a.id, a.when, ", ".join(body["channels"])))
    if r.get("job"):
        print("  ship job %s" % r["job"])


def cmd_auth_check(a):
    show({"http": request("GET", "/api/lightning/status")[0]})


def cmd_propose_batch(a):
    show(need(request("POST", "/api/director/propose", {"count": a.count}), ok=(200, 202)))


# ---------------------------------------------------------------- main

def main(argv=None):
    p = argparse.ArgumentParser(prog="holina", description="Holina Factory command line.")
    p.add_argument("--version", action="version", version="holina " + VERSION)
    # --json goes on every command, so it works after the command name too.
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument("--json", action="store_true", help="print the Factory's raw answer")
    sub = p.add_subparsers(dest="cmd", metavar="COMMAND")
    sub.required = True
    _add = sub.add_parser
    sub.add_parser = lambda *x, **k: _add(*x, parents=[common], **k)

    s = sub.add_parser("login", help="sign in with a code emailed to you")
    s.add_argument("--email")
    s.set_defaults(func=cmd_login)
    sub.add_parser("logout", help="delete the sign-in on this computer").set_defaults(func=cmd_logout)
    sub.add_parser("whoami", help="who is signed in, on which plan").set_defaults(func=cmd_whoami)
    sub.add_parser("usage", help="your plan and what is left this month").set_defaults(func=cmd_usage)
    sub.add_parser("engines", help="the engines you can render on now").set_defaults(func=cmd_engines)

    s = sub.add_parser("generate", help="start a render")
    s.add_argument("--engine", required=True, help="see: holina engines")
    s.add_argument("--prompt", required=True, help="one shot: subject, action, setting, camera, light")
    s.add_argument("--count", type=int, default=1, help="1-4 renders of this prompt")
    s.add_argument("--aspect", choices=("9:16", "16:9"))
    s.add_argument("--duration", type=float, help="seconds, where the engine takes it")
    s.add_argument("--resolution", help="LTX resolution rung, e.g. 1080")
    s.add_argument("--seed", type=int)
    s.add_argument("--image", help="an output id to start from (image-to-video engines)")
    s.add_argument("--wait", action="store_true", help="wait until it is ready")
    s.add_argument("--timeout", type=float, default=60, help="minutes to wait (default 60)")
    s.set_defaults(func=cmd_generate)

    s = sub.add_parser("status", help="where a render is")
    s.add_argument("job")
    s.set_defaults(func=cmd_status)

    s = sub.add_parser("outputs", help="your newest clips and stills")
    s.add_argument("-n", "--limit", type=int, default=10)
    s.set_defaults(func=cmd_outputs)

    s = sub.add_parser("download", help="save a clip or still")
    s.add_argument("id")
    s.add_argument("-o", "--output", help="file name (default: ID.mp4 or ID.png)")
    s.set_defaults(func=cmd_download)

    sub.add_parser("verdicts", help="what you kept and rejected in Review").set_defaults(
        func=cmd_verdicts)

    s = sub.add_parser("ship", help="preview a post; schedule it with --yes")
    s.add_argument("id")
    s.add_argument("--channels", help="comma-separated channel keys, from the preview")
    s.add_argument("--when", help="local Eastern time, e.g. 2026-10-01T09:00")
    s.add_argument("--caption")
    s.add_argument("--yes", action="store_true", help="schedule it (without this, only preview)")
    s.set_defaults(func=cmd_ship)

    s = sub.add_parser("auth", help="staff: check a service token")
    s.add_argument("what", choices=("check",))
    s.set_defaults(func=cmd_auth_check)
    s = sub.add_parser("propose-batch", help="staff: ask the director for a batch")
    s.add_argument("--count", type=int, default=60)
    s.set_defaults(func=cmd_propose_batch)

    a = p.parse_args(argv)
    try:
        a.func(a)
    except Fail as e:
        print("holina: %s" % e, file=sys.stderr)
        return 1
    except KeyboardInterrupt:
        print("\nholina: stopped waiting. A render you started keeps going.", file=sys.stderr)
        return 130
    return 0


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