#!/usr/bin/env python3
"""Crop a folder of photos through a template of components, from any machine.

    python3 crop_batch.py --key dtk_live_... --photos ./my-photos \
        --template "Banner Hero@HP" "Card Teaser@CATLP" \
        --product footwear --category Football \
        --campaign "Predator Touch" --season FW26 --phase Launch --out result.zip

    # or read the photos from a SharePoint folder your key is scoped to
    # (nothing is uploaded; the crops are written back to <folder>/crops/<job_id>/)
    python3 crop_batch.py --key dtk_live_... --photos-from "API TEST/your-team/shoot" \
        --template "Banner Hero@HP" --product apparel --category Originals

With --campaign/--season/--phase the files are named the way our content
plan names them: global-predator_touch-football-fw26-launch-hp-banner_hero-d.jpg
(-t tablet, -m mobile). "@HP" after a component is the touchpoint (HP, CATLP,
CAMLP, GLP, WGLP, MGLP, PLP, PDP, APP, CRM); leave it off and the component's
usual page is used. --select best cuts each component only from the photo(s)
that fit its shape best; a WGLP or MGLP entry is cut only from the women's or
men's photos. report.json says which photo was used for what, and why.

Needs only Python 3 (no packages). Talks to https://api.toolkitengine.com
(--base or CROP_API_BASE for another host, e.g. a local run).
Steps: create a job -> upload each photo (or name a folder) -> start -> poll
-> download the zip (-> the SharePoint folder the crops were written to).
The zip holds a folder per photo, a file per component per device, and
report.json with every crop's rectangle and warnings ("flagged" first).
"""
import argparse, re, json, mimetypes, os, sys, time, urllib.request, urllib.error, uuid
from pathlib import Path

BASE = os.environ.get("CROP_API_BASE") or "https://api.toolkitengine.com"


def call(key, method, path, body=None, headers=None, raw=False):
    req = urllib.request.Request(BASE + path, data=body, method=method)
    req.add_header("Authorization", "Bearer " + key)
    for k, v in (headers or {}).items():
        req.add_header(k, v)
    for attempt in range(8):
        try:
            with urllib.request.urlopen(req, timeout=600) as r:
                data = r.read()
                return data if raw else json.loads(data)
        except urllib.error.HTTPError as e:
            if e.code == 429 and attempt < 7:            # over the per-minute rate: wait as told
                wait = int(e.headers.get("Retry-After", "5") or 5)
                print(f"  rate limited, waiting {wait}s"); time.sleep(wait); continue
            detail = e.read().decode("utf-8", "replace")
            sys.exit(f"{method} {path} -> HTTP {e.code}: {detail[:400]}")


def multipart(field, path):
    boundary = uuid.uuid4().hex
    ctype = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
    body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"{field}\"; "
            f"filename=\"{path.name}\"\r\nContent-Type: {ctype}\r\n\r\n").encode() \
        + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
    return body, {"Content-Type": f"multipart/form-data; boundary={boundary}"}


def entry(text):
    """'Card Teaser@HP x3' → {component, touchpoint, count}. ' xN' is how many
    (different photos, or tiles for Group Media); '@PAGE' names the page."""
    count = None
    m = re.match(r"^(.*?)\s+[xX](\d+)$", text.strip())
    if m:
        text, count = m.group(1), int(m.group(2))
    if "@" not in text and count is None:
        return text
    out = {"component": text.split("@", 1)[0]}
    if "@" in text:
        out["touchpoint"] = text.split("@", 1)[1]
    if count is not None:
        out["count"] = count
    return out


def main():
    global BASE
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--key", required=True)
    ap.add_argument("--base", default=BASE, help="the API host (default: $CROP_API_BASE or %s)" % BASE)
    src = ap.add_mutually_exclusive_group(required=True)
    src.add_argument("--photos", help="local folder of JPEG/PNG/WebP photos (up to 50), uploaded one by one")
    src.add_argument("--photos-from", help='a SharePoint folder your key may read, e.g. "API TEST/your-team/shoot"')
    ap.add_argument("--no-write-back", action="store_true",
                    help="with --photos-from: do not upload the crops back beside the photos")
    ap.add_argument("--template", nargs="+", required=True, help="components as 'Component@PAGE', with ' xN' for how many, e.g. 'Card Teaser@HP x3'")
    ap.add_argument("--channel", default=".COM")
    ap.add_argument("--product", default="footwear", choices=["footwear", "apparel", "both"])
    ap.add_argument("--category", default=None, help="Originals, Football or Running")
    ap.add_argument("--shot-type", default=None, help="e.g. Close-Up")
    ap.add_argument("--select", default="all", choices=["all", "best"],
                    help="best: each component from the photo(s) that fit its shape best")
    ap.add_argument("--campaign", default=None, help='campaign name, e.g. "Predator Touch" (names the files)')
    ap.add_argument("--season", default=None, help="e.g. FW26")
    ap.add_argument("--phase", default="Launch", help="Introduce, Launch, Sustain, Exit")
    ap.add_argument("--market", default="global")
    ap.add_argument("--out", default="result.zip")
    a = ap.parse_args()
    BASE = a.base.rstrip("/")
    template = [entry(t) for t in a.template]
    body = {"template": template, "channel": a.channel, "product": a.product,
            "category": a.category, "shot_type": a.shot_type, "select": a.select}
    if a.campaign:
        if not (a.category and a.season):
            sys.exit("--campaign needs --category (Originals, Football or Running) and --season too")
        body["campaign"] = {"market": a.market, "campaign": a.campaign, "category": a.category,
                            "season": a.season, "phase": a.phase}

    photos = []
    if a.photos_from:
        body["photos_from"] = a.photos_from
        body["write_back"] = not a.no_write_back
    else:
        photos = sorted(p for p in Path(a.photos).iterdir() if p.suffix.lower() in (".jpg", ".jpeg", ".png", ".webp"))
        if not photos:
            sys.exit(f"no photos in {a.photos}")
    job = call(a.key, "POST", "/crop/v1/jobs", json.dumps(body).encode(),
               {"Content-Type": "application/json"})
    jid = job["job_id"]
    if a.photos_from:
        files = job["source"]["files"]
        print(f"job {jid}: {len(files)} photo(s) in {a.photos_from}, template {a.template}, select {a.select}")
    else:
        print(f"job {jid}: {len(photos)} photo(s), template {a.template}, select {a.select}")
    for i, p in enumerate(photos, 1):
        body, headers = multipart("images", p)
        call(a.key, "POST", job["upload_to"], body, headers)
        print(f"  uploaded {i}/{len(photos)} {p.name}")
    call(a.key, "POST", f"/crop/v1/jobs/{jid}/start")
    while True:
        st = call(a.key, "GET", f"/crop/v1/jobs/{jid}")
        print(f"  {st['status']}: {st['done']}/{st['photos']} photos done, {st['flagged']} crops to check")
        if st["status"] in ("done", "failed"):
            break
        time.sleep(3)
    if st["status"] == "failed":
        sys.exit("job failed: " + str(st.get("error")))
    Path(a.out).write_bytes(call(a.key, "GET", st["download"], raw=True))
    print(f"saved {a.out}")
    delivered = st.get("delivered_to")
    if delivered:
        print(f"crops on SharePoint: {delivered['sharepoint']} ({delivered['uploaded']} file(s)"
              f"{', ' + str(delivered['failed']) + ' failed' if delivered.get('failed') else ''})")
        if delivered.get("url"):
            print(f"  {delivered['url']}")


if __name__ == "__main__":
    main()
