#!/usr/bin/env python3
# gallery-gen.py - generates a justified full-width GALLERY_VIEW.html collage
# for every folder under an images tree on a static web server, plus small
# jpeg thumbnails in a hidden .thumbs/ dir per folder.
#
# the default gallery filename starts with a SPACE on purpose: apache
# autoindex sorts names by raw byte order and space (0x20) beats every
# printable character, so the gallery is always the first file in the
# listing, and html collapses the leading space so it displays clean.
#
# rows fill the page width edge to edge and every image keeps its exact
# aspect ratio (flexbox justified layout, no cropping). reruns only rewrite
# what changed, and folders that lose all their images get cleaned up.
#
# generated files carry a marker comment. files without the marker are never
# touched, so hand made pages are safe.

import os
import re
import sys
import html
import time
import shutil
from urllib.parse import quote

from PIL import Image, ImageOps, ImageFile

ImageFile.LOAD_TRUNCATED_IMAGES = True

# ---- config ----
WEB_ROOT = "/var/www/html"        # web server document root
IMAGES_DIR = "images"             # folder under WEB_ROOT to process, recursively
GALLERY = " GALLERY_VIEW.html"    # generated gallery page. the leading space
                                  # pins it to the top of apache's name sort,
                                  # drop it if you don't care about that
STYLESHEET = "/style.css"         # stylesheet linked from generated pages, "" for none
VIEWER = ""                       # viewer url prefix for tile links, e.g. "/view.html#"
                                  # "" links tiles straight to the original image
ROWH = 200                        # base collage row height, px
THUMB_BOX = (800, 320)            # max thumbnail size
JPEG_Q = 80                       # thumbnail jpeg quality
BG = (0xFB, 0xFA, 0xF7)           # transparency gets flattened onto this color
EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif"}
SKIP_DIRS = set()                 # folder names to leave alone, e.g. {"icons"}
OWNER = None                      # (uid, gid) to chown generated files, None to skip
INTERVAL = 60                     # seconds between sweeps, 0 = run once and exit
# ----------------

ROOT = os.path.join(WEB_ROOT, IMAGES_DIR)
MARKER = "<!-- generated by gallery-gen.py, do not edit -->"

ratio_cache = {}                  # (path, mtime, size) -> aspect ratio
warned = set()


def chown(path):
    if OWNER:
        try:
            os.chown(path, *OWNER)
        except OSError:
            pass


def apache_quote(name):
    # match apache's href escaping: same safe set, lowercase hex, so links
    # agree byte for byte with what autoindex emits for the same file
    q = quote(name, safe="$-_.+!*'(),:@&=~/")
    return re.sub(r"%[0-9A-F]{2}", lambda m: m.group(0).lower(), q)


def probe_ratio(path):
    # cheap w/h read (no pixel decode), honouring exif orientation
    st = os.stat(path)
    key = (path, st.st_mtime_ns, st.st_size)
    if key in ratio_cache:
        return ratio_cache[key]
    if path.lower().endswith(".svg"):
        ratio = svg_ratio(path)
    else:
        with Image.open(path) as im:
            w, h = im.size
            try:
                if im.getexif().get(274, 1) in (5, 6, 7, 8):
                    w, h = h, w
            except Exception:
                pass
            ratio = w / h if h else 1.0
    ratio = max(0.05, min(20.0, ratio))
    ratio_cache[key] = ratio
    return ratio


def svg_ratio(path):
    try:
        head = open(path, "r", encoding="utf-8", errors="replace").read(4096)
        m = re.search(r'viewBox\s*=\s*["\'][\d.\s,-]*?([\d.]+)[\s,]+([\d.]+)\s*["\']', head)
        if m:
            return float(m.group(1)) / float(m.group(2))
        w = re.search(r'\bwidth\s*=\s*["\']([\d.]+)', head)
        h = re.search(r'\bheight\s*=\s*["\']([\d.]+)', head)
        if w and h:
            return float(w.group(1)) / float(h.group(1))
    except Exception:
        pass
    return 1.0


def make_thumb(src, dst):
    with Image.open(src) as im:
        im = ImageOps.exif_transpose(im)
        im = im.convert("RGBA")
        im.thumbnail(THUMB_BOX, Image.LANCZOS)
        flat = Image.new("RGB", im.size, BG)
        flat.paste(im, mask=im.split()[3])
        flat.save(dst, "JPEG", quality=JPEG_Q, progressive=True)
    chown(dst)


CSS = """.collage{display:flex;flex-wrap:wrap;gap:4px;margin:.8em 0}
.collage a{position:relative;display:block;background:#eee}
.collage a i{display:block}
.collage a img{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover}
.collage::after{content:"";flex-grow:1000000}"""


def page_head(title):
    out = [
        "<!DOCTYPE html>",
        '<html lang="en">',
        "<head>",
        '<meta charset="utf-8">',
        '<meta name="viewport" content="width=device-width, initial-scale=1">',
        MARKER,
        f"<title>{title}</title>",
    ]
    if STYLESHEET:
        out.append(f'<link rel="stylesheet" href="{STYLESHEET}">')
    return out


def rel_shown(dir_path):
    rel = os.path.relpath(dir_path, WEB_ROOT).replace(os.sep, "/")
    return "/" + rel + "/"


def build_gallery(dir_path, tiles):
    shown = html.escape(rel_shown(dir_path))
    dir_url = apache_quote(rel_shown(dir_path))
    out = page_head(f"gallery of {shown[:-1]}")
    out += [
        "<style>",
        "body{max-width:none;margin:1.2rem 1.4rem;padding:0}",
        CSS,
        "#top{font-size:15px;color:#555;margin:0}",
        "</style>",
        "</head>",
        "<body>",
        f'<p id="top"><a href="./">&larr; back to {shown}</a>'
        f" &middot; {len(tiles)} images</p>",
        '<div class="collage">',
    ]
    for name, ratio, thumb_rel in tiles:
        target = VIEWER + dir_url + apache_quote(name) if VIEWER else apache_quote(name)
        href = html.escape(target)
        tsrc = html.escape(apache_quote(thumb_rel))
        alt = html.escape(name, quote=True)
        w = ratio * ROWH
        out.append(
            f'<a href="{href}" style="width:{w:.2f}px;flex-grow:{ratio * 1000:.0f}">'
            f'<i style="padding-bottom:{100 / ratio:.3f}%"></i>'
            f'<img src="{tsrc}" alt="{alt}" loading="lazy" decoding="async"></a>'
        )
    out += ["</div>", "</body>", "</html>"]
    return "\n".join(out) + "\n"


def write_if_changed(path, content):
    data = content.encode("utf-8")
    try:
        if open(path, "rb").read() == data:
            return False
    except OSError:
        pass
    tmp = path + ".tmp"
    with open(tmp, "wb") as f:
        f.write(data)
    chown(tmp)
    os.replace(tmp, path)
    return True


def ours(path):
    # True if the file doesn't exist or carries our marker
    if not os.path.exists(path):
        return True
    try:
        return MARKER in open(path, encoding="utf-8", errors="replace").read()
    except OSError:
        return False


def process_dir(dir_path, filenames):
    gal = os.path.join(dir_path, GALLERY)
    tdir = os.path.join(dir_path, ".thumbs")

    if not ours(gal):
        if dir_path not in warned:
            print(f"skip {dir_path}: {GALLERY.strip()} is not ours", flush=True)
            warned.add(dir_path)
        return

    images = sorted(
        (n for n in filenames
         if not n.startswith(".")
         and (os.path.splitext(n)[1].lower() in EXTS or n.lower().endswith(".svg"))),
        key=str.lower,
    )

    tiles, want_thumbs = [], set()
    for name in images:
        src = os.path.join(dir_path, name)
        try:
            ratio = probe_ratio(src)
        except Exception as e:
            if src not in warned:
                print(f"skip {src}: {e}", flush=True)
                warned.add(src)
            continue
        if name.lower().endswith(".svg"):
            tiles.append((name, ratio, name))
        else:
            tname = name + ".jpg"
            want_thumbs.add(tname)
            tiles.append((name, ratio, ".thumbs/" + tname))

    if not tiles:
        # folder lost all its images: clean up after ourselves
        if os.path.exists(gal):
            os.remove(gal)
            print(f"removed {gal} (no images left)", flush=True)
        if os.path.isdir(tdir):
            shutil.rmtree(tdir, ignore_errors=True)
        return

    if want_thumbs:
        if not os.path.isdir(tdir):
            os.makedirs(tdir, exist_ok=True)
            chown(tdir)
        for tname in sorted(want_thumbs):
            src = os.path.join(dir_path, tname[:-4])
            dst = os.path.join(tdir, tname)
            try:
                if not os.path.exists(dst) or os.path.getmtime(dst) < os.path.getmtime(src):
                    make_thumb(src, dst)
            except Exception as e:
                if dst not in warned:
                    print(f"thumb failed {src}: {e}", flush=True)
                    warned.add(dst)
        for stray in os.listdir(tdir):
            if stray not in want_thumbs:
                os.remove(os.path.join(tdir, stray))

    if write_if_changed(gal, build_gallery(dir_path, tiles)):
        print(f"wrote {gal} ({len(tiles)} tiles)", flush=True)


def sweep():
    for dir_path, dirnames, filenames in os.walk(ROOT):
        dirnames[:] = [d for d in dirnames if not d.startswith(".") and d not in SKIP_DIRS]
        try:
            process_dir(dir_path, filenames)
        except Exception as e:
            print(f"error in {dir_path}: {e}", flush=True)


if __name__ == "__main__":
    print("gallery-gen starting", flush=True)
    while True:
        sweep()
        if INTERVAL <= 0 or "--once" in sys.argv:
            break
        time.sleep(INTERVAL)
