#!/usr/bin/env python3
"""Derive a standard set of sizes from every image in a folder.

    python3 derive_sizes.py
    python3 derive_sizes.py --src ../out/frames --out ../out/derived --preset og,thumb

Five presets, two fit modes, and one policy about enlargement. A small amount of upsampling is
invisible and useful, so it is allowed up to a threshold, 1.2 times by default. Past that the image
is padded rather than stretched, and the record says which happened and why, because a stretched
image is a quiet lie about how good the original was.

cover   scale so the target is filled, then crop to the target from a focal anchor
contain scale so the whole image fits, then pad with the brand page colour

The crop box, the scale, and the reason for every decision are written to derivatives.json next to
the files, so a person can check the maths without opening an image.

Apex Instruments is fictional. Every figure here is demo data.
"""

import argparse
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import brandkit as bk  # noqa: E402

PRESETS = [
    {"name": "og", "w": 1200, "h": 630, "fit": "cover", "use": "Link preview on a page or a post"},
    {"name": "card", "w": 640, "h": 360, "fit": "cover", "use": "Card image in a listing"},
    {"name": "thumb", "w": 400, "h": 400, "fit": "cover", "use": "Square thumbnail in the asset index"},
    {"name": "portrait", "w": 1080, "h": 1350, "fit": "cover", "use": "Tall social placement"},
    {"name": "email", "w": 600, "h": 200, "fit": "contain", "use": "Header strip in an email, padded not cropped"},
]


def plan(sw, sh, preset, focal=(0.5, 0.5), max_upscale=1.2):
    """Work out the scale, the crop box and the padding for one source and one preset.

    A modest enlargement is fine and a large one is not, so the policy is a threshold rather than a
    ban. Past the threshold the image is padded instead of stretched, and the record says which."""
    tw, th = preset["w"], preset["h"]
    fit = preset["fit"]
    notes = []
    if fit == "cover":
        scale = max(tw / float(sw), th / float(sh))
        if scale > max_upscale:
            notes.append("a cover crop would have enlarged the source %.2f times, past the %.2f "
                         "limit, so it was padded instead" % (scale, max_upscale))
            fit = "contain"
        else:
            if scale > 1:
                notes.append("enlarged %.2f times, inside the %.2f limit" % (scale, max_upscale))
            # the region of the source that survives, placed on the focal point and clamped inside
            cw, ch = tw / scale, th / scale
            cx = min(max(focal[0] * sw - cw / 2.0, 0), sw - cw)
            cy = min(max(focal[1] * sh - ch / 2.0, 0), sh - ch)
            cut = 1 - (cw * ch) / float(sw * sh)
            if cut > 0.001:
                notes.append("%.0f%% of the source area was cropped away" % (cut * 100))
            return {"mode": "cover", "scale": round(scale, 5),
                    "crop": [round(cx, 2), round(cy, 2), round(cw, 2), round(ch, 2)],
                    "pad": [0, 0], "target": [tw, th], "source": [sw, sh],
                    "notes": notes, "focal": list(focal)}
    scale = min(tw / float(sw), th / float(sh))
    if scale > max_upscale:
        scale = 1.0
        notes.append("held at the source scale rather than enlarged")
    dw, dh = sw * scale, sh * scale
    pad = [(tw - dw) / 2.0, (th - dh) / 2.0]
    if pad[0] > 0.5 or pad[1] > 0.5:
        notes.append("padded by %d by %d pixels to reach the frame" % (round(pad[0]), round(pad[1])))
    return {"mode": "contain", "scale": round(scale, 5), "crop": [0, 0, sw, sh],
            "pad": [round(pad[0], 2), round(pad[1], 2)], "target": [tw, th], "source": [sw, sh],
            "notes": notes, "focal": list(focal)}


def derive_png(src, out_path, p, background=bk.PAPER):
    from PIL import Image
    with Image.open(src) as im:
        im = im.convert("RGB")
        tw, th = p["target"]
        if p["mode"] == "cover":
            cx, cy, cw, ch = p["crop"]
            box = (int(round(cx)), int(round(cy)), int(round(cx + cw)), int(round(cy + ch)))
            out = im.crop(box).resize((tw, th), Image.LANCZOS)
        else:
            dw = max(1, int(round(im.width * p["scale"])))
            dh = max(1, int(round(im.height * p["scale"])))
            out = Image.new("RGB", (tw, th), bk.hex_to_rgb(background))
            out.paste(im.resize((dw, dh), Image.LANCZOS),
                      (int(round(p["pad"][0])), int(round(p["pad"][1]))))
    return bk.save_png(out, out_path)


def derive_svg(src, out_path, p, clip_id, background=bk.PAPER):
    inner, vb = bk.svg_inner(src)
    tw, th = p["target"]
    s = p["scale"]
    if p["mode"] == "cover":
        dx, dy = -p["crop"][0] * s, -p["crop"][1] * s
    else:
        dx, dy = p["pad"][0], p["pad"][1]
    doc = (
        '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="%d" height="%d" role="img">'
        '<title>%s at %dx%d, %s</title>'
        '<defs><clipPath id="%s"><rect x="0" y="0" width="%d" height="%d"/></clipPath></defs>'
        '<rect x="0" y="0" width="%d" height="%d" fill="%s"/>'
        '<g clip-path="url(#%s)"><g transform="translate(%s,%s) scale(%s)">%s</g></g></svg>'
        % (tw, th, tw, th,
           bk.esc(os.path.basename(src)), tw, th, p["mode"],
           clip_id, tw, th, tw, th, background, clip_id,
           round(dx, 3), round(dy, 3), round(s, 5), inner)
    )
    bk.ensure_dir(out_path)
    with open(out_path, "w", encoding="utf-8") as fh:
        fh.write(doc)
    return out_path


def main(argv=None):
    here = os.path.dirname(os.path.abspath(__file__))
    ap = argparse.ArgumentParser(description="Derive standard sizes from a folder of images.")
    ap.add_argument("--src", default=os.path.join(here, "../out/frames"))
    ap.add_argument("--out", default=os.path.join(here, "../out/derived"))
    ap.add_argument("--focals", default=os.path.join(here, "input/focals.json"))
    ap.add_argument("--preset", default="", help="comma separated preset names, default all")
    ap.add_argument("--max-upscale", type=float, default=1.2, dest="max_upscale",
                    help="how far a source may be enlarged before it is padded instead")
    a = ap.parse_args(argv)

    wanted = [x.strip() for x in a.preset.split(",") if x.strip()]
    presets = [p for p in PRESETS if not wanted or p["name"] in wanted]
    focals = {}
    if os.path.exists(a.focals):
        focals = bk.load_json(a.focals).get("focals", {})

    sources = []
    for name in sorted(os.listdir(a.src)):
        base, ext = os.path.splitext(name)
        if ext.lower() in (".png", ".svg"):
            sources.append((base, ext.lower(), os.path.join(a.src, name)))
    if not sources:
        print("no PNG or SVG files in %s" % a.src)
        return 1

    pillow = bk.have_pillow()
    if not pillow:
        print("Pillow is not installed, so only the SVG derivatives are written.")

    rows, made = [], 0
    for base, ext, path in sources:
        size = bk.image_size(path)
        if not size:
            print("skip %s, could not read its size" % name)
            continue
        sw, sh = size
        focal = tuple(focals.get(base, [0.5, 0.5]))
        for pr in presets:
            p = plan(sw, sh, pr, focal, max_upscale=a.max_upscale)
            out_path = os.path.join(a.out, pr["name"], base + ext)
            if ext == ".svg":
                derive_svg(path, out_path, p, clip_id="clip-%s-%s" % (base, pr["name"]))
            elif pillow:
                derive_png(path, out_path, p)
            else:
                continue
            made += 1
            rows.append({
                "source": os.path.basename(path), "sourceSize": [sw, sh],
                "preset": pr["name"], "use": pr["use"], "fit": pr["fit"],
                "target": p["target"], "mode": p["mode"], "scale": p["scale"],
                "crop": p["crop"], "pad": p["pad"], "focal": p["focal"],
                "notes": p["notes"], "out": os.path.relpath(out_path, a.out),
                "bytes": os.path.getsize(out_path)
            })
        print("derived %-14s %s" % (base + ext, ", ".join(pr["name"] for pr in presets)))

    bk.save_json(os.path.join(a.out, "derivatives.json"), {
        "presets": presets,
        "policy": "enlarge by at most %.2f times, otherwise pad rather than stretch" % a.max_upscale,
        "maxUpscale": a.max_upscale,
        "sources": len(sources), "derivatives": len(rows), "files": made,
        "pillow": pillow, "rows": rows
    })
    print("%d sources, %d derivatives, into %s" % (len(sources), len(rows), a.out))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
