#!/usr/bin/env python3
"""Mockups portfolio Cabinet Hexagone — captures Chrome + mises en situation."""

from __future__ import annotations

import math
from pathlib import Path

import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont

ROOT = Path(__file__).resolve().parents[1]
SHOTS = ROOT / "assets" / "cabinet-hexagone-mockups" / "screenshots"
OUT = ROOT / "assets" / "cabinet-hexagone-mockups"
BEZELS = ROOT / "assets" / "device-frames" / "png"
IMAC_BEZEL = BEZELS / "imac-m4-24-silver.png"
IPHONE_BEZEL = BEZELS / "iphone-14-starlight-portrait.png"

FONT = "/System/Library/Fonts/Supplemental/Arial.ttf"
FONT_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"

ACCENT = (0, 128, 128)  # teal médical
PALETTE = [(6, 28, 42), (0, 72, 88), (0, 128, 128), (18, 52, 68)]
# Couleurs charte Cabinet Hexagone (charbon, teal, cyan clair)
FEATURED_PALETTE = [(36, 40, 44), (0, 72, 88), (0, 128, 128), (94, 184, 196)]
FEATURED_SIZE = (1600, 1067)  # ratio portfolio (aligné Mister Auto / hero WP large)
THUMB_SIZE = (930, 698)       # vignette liste réalisations
SITE_URL = "cabinet-hexagone.com"


def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
    try:
        return ImageFont.truetype(FONT_BOLD if bold else FONT, size)
    except OSError:
        return ImageFont.load_default()


def mesh_gradient(w: int, h: int, palette: list[tuple[int, int, int]]) -> Image.Image:
    arr = np.zeros((h, w, 3), dtype=np.float32)
    orbs = [
        (0.18, 0.22, palette[0], 0.55),
        (0.82, 0.15, palette[1], 0.50),
        (0.50, 0.88, palette[2], 0.45),
        (0.72, 0.62, palette[3], 0.35),
    ]
    yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
    nx, ny = xx / w, yy / h
    for ox, oy, color, strength in orbs:
        dist = ((nx - ox) ** 2 + (ny - oy) ** 2) * (1.0 / strength)
        weight = np.exp(-dist * 3.2)[..., None]
        arr += weight * np.array(color, dtype=np.float32)
    arr = np.clip(arr, 0, 255).astype(np.uint8)
    fade = np.linspace(1.0, 0.82, h, dtype=np.float32)[:, None, None]
    arr = (arr.astype(np.float32) * fade).astype(np.uint8)
    return Image.fromarray(arr, "RGB")


def cinematic_mesh_gradient(
    w: int,
    h: int,
    palette: list[tuple[int, int, int]],
    *,
    accent: tuple[int, int, int] = ACCENT,
) -> Image.Image:
    """Fond mesh WOW : orbes marque + halo accent central + vignette."""
    base = mesh_gradient(w, h, palette).convert("RGBA")
    halo = Image.new("RGBA", (w, h), (0, 0, 0, 0))
    draw = ImageDraw.Draw(halo)
    cx, cy = int(w * 0.5), int(h * 0.46)
    rx, ry = int(w * 0.42), int(h * 0.38)
    draw.ellipse((cx - rx, cy - ry, cx + rx, cy + ry), fill=accent + (72,))
    halo = halo.filter(ImageFilter.GaussianBlur(90))
    base.alpha_composite(halo)

    vig = Image.new("L", (w, h), 0)
    ImageDraw.Draw(vig).ellipse((-w // 5, -h // 6, w + w // 5, h + h // 4), fill=255)
    vig = vig.filter(ImageFilter.GaussianBlur(w // 7))
    dark = Image.new("RGBA", (w, h), (0, 0, 0, 255))
    dark.putalpha(Image.eval(vig, lambda p: int(p * 0.22)))
    base.alpha_composite(dark)
    return base.convert("RGB")


def tinted_shadow(
    device: Image.Image,
    color: tuple[int, int, int],
    *,
    blur_px: int,
    opacity: int,
    offset: tuple[int, int],
) -> Image.Image:
    """Ombre portée teintée par la couleur d'accent."""
    alpha = device.split()[3]
    pad = blur_px * 2
    w, h = device.size
    layer = Image.new("RGBA", (w + pad * 2, h + pad * 2), (0, 0, 0, 0))
    mask = alpha.point(lambda p: min(255, int(p * opacity / 255)))
    shadow = Image.new("RGBA", device.size, color + (255,))
    shadow.putalpha(mask)
    shadow = shadow.filter(ImageFilter.GaussianBlur(blur_px))
    layer.paste(shadow, (pad + offset[0], pad + offset[1]))
    return layer


def floor_reflection(device: Image.Image, *, alpha: int = 30, fade: float = 0.55) -> Image.Image:
    """Reflet au sol sous le device."""
    w, h = device.size
    refl_h = max(24, int(h * 0.22))
    crop = device.crop((0, h - refl_h, w, h)).transpose(Image.Transpose.FLIP_TOP_BOTTOM)
    mask = Image.new("L", (w, crop.height), 0)
    for y in range(crop.height):
        t = y / crop.height
        val = int(alpha * max(0, 1 - t / fade) ** 1.4)
        ImageDraw.Draw(mask).line((0, y, w, y), fill=val)
    crop.putalpha(mask)
    return crop


def accent_edge_glow(device: Image.Image, color: tuple[int, int, int], *, strength: int = 22) -> Image.Image:
    """Léger glow coloré sur le pourtour uniquement (pas sur tout l'écran)."""
    alpha = device.split()[3]
    outer = np.array(alpha.filter(ImageFilter.MaxFilter(7)), dtype=np.int16)
    inner = np.array(alpha.filter(ImageFilter.MinFilter(7)), dtype=np.int16)
    edge = np.clip(outer - inner, 0, 255).astype(np.uint8)
    edge_mask = Image.fromarray(edge, "L").filter(ImageFilter.GaussianBlur(4))
    glow = Image.new("RGBA", device.size, color + (strength,))
    glow.putalpha(edge_mask)
    out = device.copy()
    out.alpha_composite(glow)
    return out


def apply_film_grain(img: Image.Image, amount: float = 0.012) -> Image.Image:
    """Grain film discret."""
    arr = np.array(img.convert("RGB"), dtype=np.float32)
    arr += np.random.normal(0, amount * 255, arr.shape)
    return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGB")


def drop_shadow(size: tuple[int, int], radius: int, blur_px: int, opacity: int, offset: tuple[int, int]) -> Image.Image:
    w, h = size
    sw, sh = w + blur_px * 4, h + blur_px * 4
    layer = Image.new("RGBA", (sw, sh), (0, 0, 0, 0))
    draw = ImageDraw.Draw(layer)
    ox, oy = blur_px * 2 + offset[0], blur_px * 2 + offset[1]
    draw.rounded_rectangle((ox, oy, ox + w - 1, oy + h - 1), radius=radius, fill=(0, 0, 0, opacity))
    return layer.filter(ImageFilter.GaussianBlur(blur_px))


def browser_shell(content: Image.Image, url: str, accent: tuple[int, int, int]) -> Image.Image:
    w = content.width
    bar_h = max(52, int(w * 0.038))
    radius = max(14, int(w * 0.014))
    total_h = bar_h + content.height

    shell = Image.new("RGBA", (w, total_h), (0, 0, 0, 0))
    draw = ImageDraw.Draw(shell)
    draw.rounded_rectangle((0, 0, w - 1, total_h - 1), radius=radius, fill=(18, 20, 26, 255))
    draw.rounded_rectangle((1, 1, w - 2, total_h - 2), radius=radius - 1, fill=(248, 249, 252, 255))
    draw.rounded_rectangle((1, 1, w - 2, bar_h), radius=radius, fill=(240, 242, 247, 255))
    draw.line((8, bar_h, w - 8, bar_h), fill=(220, 224, 232), width=1)

    for i, c in enumerate([(255, 96, 88), (255, 189, 46), (40, 202, 65)]):
        cx = 22 + i * 20
        draw.ellipse((cx - 6, 16, cx + 6, 28), fill=c + (255,))

    pill_x1, pill_x2 = 88, w - 20
    draw.rounded_rectangle((pill_x1, 12, pill_x2, bar_h - 12), radius=8, fill=(255, 255, 255, 255))
    draw.rounded_rectangle((pill_x1, 12, pill_x2, bar_h - 12), radius=8, outline=(216, 220, 228), width=1)
    draw.rounded_rectangle((pill_x1 + 10, 18, pill_x1 + 18, bar_h - 18), radius=2, fill=accent + (255,))
    f = font(max(13, bar_h // 4))
    draw.text((pill_x1 + 26, bar_h // 2 - 7), url, fill=(70, 76, 88), font=f)
    shell.paste(content, (0, bar_h))
    return shell


def reflection(floater: Image.Image, alpha: int = 55, fade_start: float = 0.35) -> Image.Image:
    w, h = floater.size
    refl_h = int(h * 0.28)
    crop = floater.crop((0, h - refl_h, w, h)).transpose(Image.Transpose.FLIP_TOP_BOTTOM)
    mask = Image.new("L", (w, crop.height), 0)
    for y in range(crop.height):
        t = y / crop.height
        val = int(alpha * max(0, 1 - t / fade_start) ** 1.6)
        ImageDraw.Draw(mask).line((0, y, w, y), fill=val)
    crop.putalpha(mask)
    return crop


def compose_showcase(
    screenshot: Image.Image,
    *,
    title: str,
    subtitle: str,
    url: str,
    canvas_size: tuple[int, int] = (2400, 1600),
    content_width: int = 1720,
    tilt: float = 0.0,
) -> Image.Image:
    cw, ch = canvas_size
    bg = mesh_gradient(cw, ch, PALETTE)

    scale = content_width / screenshot.width
    content = screenshot.resize(
        (content_width, int(screenshot.height * scale)),
        Image.Resampling.LANCZOS,
    )
    shell = browser_shell(content, url, ACCENT)
    if abs(tilt) > 0.01:
        shell = shell.rotate(tilt, resample=Image.Resampling.BICUBIC, expand=True)

    sw, sh = shell.size
    pad = 80
    fx = (cw - sw) // 2
    fy = (ch - sh) // 2 + 40

    halo = Image.new("RGBA", (sw + 200, sh + 200), (0, 0, 0, 0))
    ImageDraw.Draw(halo).ellipse((0, 0, sw + 199, sh + 199), fill=ACCENT + (38,))
    halo = halo.filter(ImageFilter.GaussianBlur(60))
    bg_rgba = bg.convert("RGBA")
    bg_rgba.alpha_composite(halo, (fx - 100, fy - 80))

    shadow = drop_shadow((sw, sh), radius=18, blur_px=52, opacity=110, offset=(0, 32))
    shadow2 = drop_shadow((sw, sh), radius=18, blur_px=18, opacity=50, offset=(0, 8))
    bg_rgba.alpha_composite(shadow, (fx - 104, fy - 78))
    bg_rgba.alpha_composite(shadow2, (fx - 36, fy - 10))
    bg_rgba.alpha_composite(shell, (fx, fy))

    refl = reflection(shell, alpha=48)
    bg_rgba.alpha_composite(refl, (fx, fy + sh - 8))

    draw = ImageDraw.Draw(bg_rgba)
    draw.text((pad, pad), title, fill=(255, 255, 255, 245), font=font(56, bold=True))
    draw.text((pad, pad + 68), subtitle, fill=(255, 255, 255, 170), font=font(26))

    bf = font(18, bold=True)
    bx, by = pad, pad + 118
    draw.rounded_rectangle((bx, by, bx + 180, by + 40), radius=20, fill=ACCENT + (220,))
    tw = draw.textlength("WordPress", font=bf)
    draw.text((bx + (180 - tw) / 2, by + 10), "WordPress", fill=(255, 255, 255), font=bf)

    vig = Image.new("L", (cw, ch), 0)
    ImageDraw.Draw(vig).ellipse((-cw // 4, -ch // 6, cw + cw // 4, ch + ch // 3), fill=255)
    vig = vig.filter(ImageFilter.GaussianBlur(cw // 8))
    dark = Image.new("RGBA", (cw, ch), (0, 0, 0, 0))
    dark.putalpha(Image.eval(vig, lambda p: 255 - int(p * 0.42)))
    bg_rgba.alpha_composite(dark, (0, 0))
    return bg_rgba.convert("RGB")


def phone_mockup(screenshot: Image.Image, *, title: str) -> Image.Image:
    cw, ch = 1200, 1600
    bg = mesh_gradient(cw, ch, PALETTE).convert("RGBA")
    draw = ImageDraw.Draw(bg)
    draw.text((60, 60), title, fill=(255, 255, 255), font=font(42, bold=True))
    draw.text((60, 118), "Vue mobile responsive", fill=(200, 220, 220), font=font(22))

    pw, ph = 390, 780
    frame_w, frame_h = pw + 36, ph + 100
    ox, oy = (cw - frame_w) // 2, 260

    frame = Image.new("RGBA", (frame_w, frame_h), (0, 0, 0, 0))
    fd = ImageDraw.Draw(frame)
    fd.rounded_rectangle((0, 0, frame_w - 1, frame_h - 1), radius=44, fill=(22, 26, 32, 255))
    fd.rounded_rectangle((10, 10, frame_w - 11, frame_h - 11), radius=36, fill=(8, 8, 10, 255))
    notch_w = 120
    fd.rounded_rectangle(
        ((frame_w - notch_w) // 2, 18, (frame_w + notch_w) // 2, 34),
        radius=10,
        fill=(30, 30, 34, 255),
    )

    scale = pw / screenshot.width
    content = screenshot.resize((pw, int(screenshot.height * scale)), Image.Resampling.LANCZOS)
    if content.height > ph:
        content = content.crop((0, 0, pw, ph))

    shadow = drop_shadow((frame_w, frame_h), 36, 40, 100, (0, 28))
    bg.alpha_composite(shadow, (ox - 40, oy - 30))
    bg.alpha_composite(frame, (ox, oy))
    bg.paste(content, (ox + 18, oy + 56))
    return bg.convert("RGB")


def crop_viewport(img: Image.Image, height: int = 1200) -> Image.Image:
    return img.crop((0, 0, img.width, min(height, img.height)))


def rounded_mask(size: tuple[int, int], radius: int) -> Image.Image:
    mask = Image.new("L", size, 0)
    ImageDraw.Draw(mask).rounded_rectangle((0, 0, size[0] - 1, size[1] - 1), radius=radius, fill=255)
    return mask


def clip_rounded(img: Image.Image, radius: int) -> Image.Image:
    """Masque une capture avec des coins arrondis (contenu ne dépasse pas)."""
    out = img.convert("RGBA")
    out.putalpha(rounded_mask(img.size, radius))
    return out


def clip_bottom_rounded(img: Image.Image, radius: int) -> Image.Image:
    """Coins arrondis en bas seulement — pour la zone contenu du navigateur."""
    w, h = img.size
    mask = Image.new("L", (w, h + radius), 0)
    ImageDraw.Draw(mask).rounded_rectangle((0, 0, w - 1, h + radius - 1), radius=radius, fill=255)
    mask = mask.crop((0, 0, w, h))
    out = img.convert("RGBA")
    out.putalpha(mask)
    return out


def inset_mask(mask: Image.Image, px: int) -> Image.Image:
    """Réduit le masque pour éviter tout débordement aux bords."""
    if px <= 0:
        return mask
    size = px * 2 + 1
    return mask.filter(ImageFilter.MinFilter(size))


def screen_mask_from_bezel(bezel: Image.Image) -> tuple[Image.Image, tuple[int, int, int, int]]:
    """Masque pixel-perfect de la zone écran (coins arrondis inclus).

    iMac : placeholder gris foncé opaque (petite zone rectangulaire arrondie).
    iPhone : trou transparent à l'intérieur du cadre métallique.
    """
    arr = np.array(bezel.convert("RGBA"))
    rgb, alpha = arr[:, :, :3], arr[:, :, 3]

    opaque_dark = (rgb.max(axis=2) < 80) & (alpha > 200)

    hole = alpha < 20
    opaque = alpha > 200
    inner_hole = np.zeros_like(hole)
    if opaque.any():
        oys, oxs = np.where(opaque)
        ox1, oy1, ox2, oy2 = int(oxs.min()), int(oys.min()), int(oxs.max()) + 1, int(oys.max()) + 1
        margin = 8
        inner_hole = hole.copy()
        inner_hole[: oy1 + margin, :] = False
        inner_hole[oy2 - margin :, :] = False
        inner_hole[:, : ox1 + margin] = False
        inner_hole[:, ox2 - margin :] = False

    # Ne pas confondre le grand « vide » interne de l'iMac avec la zone écran (iPhone).
    if opaque_dark.sum() > 5000:
        screen = opaque_dark
    elif inner_hole.sum() > 5000:
        screen = inner_hole
    else:
        raise ValueError("zone écran introuvable dans le cadre device")

    ys, xs = np.where(screen)
    if len(xs) == 0:
        raise ValueError("zone écran introuvable dans le cadre device")
    box = int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1
    mask = Image.fromarray((screen * 255).astype(np.uint8), "L")
    return mask, box


def device_bezel_ring(
    size: tuple[int, int],
    outer_radius: int,
    inner_box: tuple[int, int, int, int],
    inner_radius: int,
) -> Image.Image:
    """Anneau de cadre (extérieur − trou intérieur) pour recouvrir les coins de l'écran."""
    w, h = size
    outer = Image.new("L", (w, h), 0)
    inner = Image.new("L", (w, h), 0)
    ImageDraw.Draw(outer).rounded_rectangle((0, 0, w - 1, h - 1), radius=outer_radius, fill=255)
    ix1, iy1, ix2, iy2 = inner_box
    ImageDraw.Draw(inner).rounded_rectangle((ix1, iy1, ix2, iy2), radius=inner_radius, fill=255)
    ring = Image.fromarray(
        np.clip(np.array(outer, dtype=np.int16) - np.array(inner, dtype=np.int16), 0, 255).astype(np.uint8),
        "L",
    )
    return ring


def mockup_phone_frame(screenshot: Image.Image, *, screen_w: int = 300) -> Image.Image:
    """iPhone style référence — cadre blanc, capture strictement masquée aux coins."""
    target_h = int(screen_w * 2.12)
    content = resize_cover(screenshot, (screen_w, target_h))

    pad = max(10, int(screen_w * 0.04))
    screen_r = max(28, int(screen_w * 0.162))
    outer_r = screen_r + pad
    fw, fh = screen_w + pad * 2, content.height + pad * 2

    frame = Image.new("RGBA", (fw, fh), (0, 0, 0, 0))
    clipped = clip_rounded(content.convert("RGBA"), screen_r)
    alpha = inset_mask(clipped.split()[3], max(3, int(screen_w * 0.016)))
    clipped.putalpha(alpha)
    frame.paste(clipped, (pad, pad), clipped)

    ring = device_bezel_ring(
        (fw, fh),
        outer_r,
        (pad, pad, pad + screen_w - 1, pad + content.height - 1),
        screen_r,
    )
    bezel = Image.new("RGBA", (fw, fh), (252, 252, 254, 255))
    bezel.putalpha(ring)
    frame.alpha_composite(bezel)
    return frame


def resize_cover(img: Image.Image, size: tuple[int, int]) -> Image.Image:
    tw, th = size
    scale = max(tw / img.width, th / img.height)
    resized = img.resize(
        (max(1, int(img.width * scale)), max(1, int(img.height * scale))),
        Image.Resampling.LANCZOS,
    )
    left = (resized.width - tw) // 2
    top = 0
    return resized.crop((left, top, left + tw, top + th))


def safari_browser_window(
    screenshot: Image.Image,
    *,
    content_width: int = 1080,
    viewport_ratio: float = 10 / 16,
) -> Image.Image:
    """Fenêtre navigateur style Safari (référence mockup) — barre claire, sans texte."""
    w = content_width
    bar_h = max(46, int(w * 0.042))
    radius = max(14, int(w * 0.013))
    content_h = int(content_width * viewport_ratio)
    content = resize_cover(crop_viewport(screenshot, int(screenshot.height * 0.85)), (content_width, content_h))
    total_h = bar_h + content.height

    win = Image.new("RGBA", (w, total_h), (0, 0, 0, 0))
    draw = ImageDraw.Draw(win)
    draw.rounded_rectangle((0, 0, w - 1, total_h - 1), radius=radius, fill=(255, 255, 255, 255))

    inner_mask = clip_bottom_rounded(Image.new("RGB", (w, content.height), (255, 255, 255)), radius).split()[3]
    inner_mask = inset_mask(inner_mask, 3)
    content.putalpha(inner_mask)
    win.paste(content, (0, bar_h), content)

    toolbar = Image.new("RGBA", (w, bar_h + radius), (0, 0, 0, 0))
    tdraw = ImageDraw.Draw(toolbar)
    tdraw.rounded_rectangle((0, 0, w - 1, bar_h + radius), radius=radius, fill=(245, 246, 248, 255))
    tdraw.line((0, bar_h, w - 1, bar_h), fill=(228, 230, 236), width=1)
    cy = bar_h // 2
    for i, color in enumerate([(255, 95, 87), (255, 189, 46), (40, 202, 65)]):
        cx = 20 + i * 18
        tdraw.ellipse((cx - 5, cy - 5, cx + 5, cy + 5), fill=color + (255,))
    tdraw.polygon([(78, cy), (86, cy - 5), (86, cy + 5)], fill=(170, 174, 184, 255))
    tdraw.polygon([(104, cy), (96, cy - 5), (96, cy + 5)], fill=(198, 202, 210, 255))
    pill_w = int(w * 0.40)
    px1 = (w - pill_w) // 2
    tdraw.rounded_rectangle((px1, cy - 13, px1 + pill_w, cy + 13), radius=11, fill=(255, 255, 255, 255))
    tdraw.rounded_rectangle(
        (px1, cy - 13, px1 + pill_w, cy + 13), radius=11, outline=(218, 222, 228), width=1
    )
    sx, sy = px1 + 14, cy
    tdraw.ellipse((sx - 5, sy - 5, sx + 5, sy + 5), outline=(175, 180, 190), width=1)
    tdraw.line((sx + 3, sy + 3, sx + 8, sy + 8), fill=(175, 180, 190), width=1)
    tdraw.rounded_rectangle((w - 78, cy - 9, w - 58, cy + 9), radius=4, fill=(210, 214, 222, 255))
    tdraw.ellipse((w - 44, cy - 7, w - 30, cy + 7), fill=(210, 214, 222, 255))
    win.alpha_composite(toolbar, (0, 0))

    win.putalpha(rounded_mask((w, total_h), radius))
    return win


def detect_screen_box(bezel: Image.Image) -> tuple[int, int, int, int]:
    """Repère la zone écran (gris foncé) dans les cadres Apple officiels."""
    arr = np.array(bezel.convert("RGBA"))
    rgb, alpha = arr[:, :, :3], arr[:, :, 3]
    screen = (rgb.max(axis=2) < 80) & (alpha > 200)
    ys, xs = np.where(screen)
    if len(xs) == 0:
        raise ValueError("zone écran introuvable dans le cadre device")
    return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1


def prepare_bezel(bezel: Image.Image) -> Image.Image:
    """Fond noir → transparent pour le compositing sur dégradé."""
    arr = np.array(bezel.convert("RGBA"))
    rgb = arr[:, :, :3]
    black_bg = rgb.sum(axis=2) == 0
    arr[black_bg, 3] = 0
    return Image.fromarray(arr, "RGBA")


def fit_screenshot(screenshot: Image.Image, size: tuple[int, int]) -> Image.Image:
    """Redimensionne et recadre (haut) pour remplir la zone écran."""
    tw, th = size
    scale = max(tw / screenshot.width, th / screenshot.height)
    resized = screenshot.resize(
        (max(1, int(screenshot.width * scale)), max(1, int(screenshot.height * scale))),
        Image.Resampling.LANCZOS,
    )
    left = (resized.width - tw) // 2
    return resized.crop((left, 0, left + tw, th))


def screen_glass_glare(
    device: Image.Image,
    box: tuple[int, int, int, int],
    *,
    strength: int = 22,
) -> Image.Image:
    """Reflet vitré discret sur la dalle (réalisme photo)."""
    x1, y1, x2, y2 = box
    sw, sh = x2 - x1, y2 - y1
    if sw < 4 or sh < 4:
        return device
    yy, xx = np.mgrid[0:sh, 0:sw].astype(np.float32)
    t = np.clip(1.0 - (xx / sw * 0.62 + yy / sh * 0.38), 0.0, 1.0)
    alpha = (strength * t * t).astype(np.uint8)
    glare = np.zeros((sh, sw, 4), dtype=np.uint8)
    glare[:, :, 3] = alpha
    overlay = Image.fromarray(glare, "RGBA")
    out = device.copy()
    out.alpha_composite(overlay, (x1, y1))
    return out


def composite_apple_bezel(bezel_path: Path, screenshot: Image.Image, *, inset_px: int = 2) -> Image.Image:
    """Insère une capture dans un cadre Apple — masque strict, cadre recouvrant les coins."""
    bezel = prepare_bezel(Image.open(bezel_path).convert("RGBA"))
    screen_mask, (x1, y1, x2, y2) = screen_mask_from_bezel(bezel)
    sw, sh = x2 - x1, y2 - y1
    local_mask = screen_mask.crop((x1, y1, x2, y2))
    local_mask = inset_mask(local_mask, max(inset_px, int(min(sw, sh) * 0.003)))

    content = fit_screenshot(screenshot.convert("RGB"), (sw, sh)).convert("RGBA")
    content.putalpha(local_mask)

    out = Image.new("RGBA", bezel.size, (0, 0, 0, 0))
    out.paste(content, (x1, y1), content)

    bez_arr = np.array(bezel)
    out_arr = np.array(out)
    interior = np.zeros((bezel.height, bezel.width), dtype=bool)
    interior[y1:y2, x1:x2] = np.array(local_mask) > 128
    chassis = (bez_arr[:, :, 3] > 8) & (~interior)
    for ch in range(3):
        out_arr[:, :, ch] = np.where(chassis, bez_arr[:, :, ch], out_arr[:, :, ch])
    out_arr[:, :, 3] = np.where(
        chassis | interior,
        np.maximum(bez_arr[:, :, 3], out_arr[:, :, 3]),
        out_arr[:, :, 3],
    )
    out = Image.fromarray(out_arr, "RGBA")
    return screen_glass_glare(out, (x1, y1, x2, y2))


def scale_rgba(img: Image.Image, height: int) -> Image.Image:
    ratio = height / img.height
    return img.resize((max(1, int(img.width * ratio)), height), Image.Resampling.LANCZOS)


def silhouette_shadow(
    device: Image.Image,
    *,
    blur_px: int,
    opacity: int,
    offset: tuple[int, int],
) -> Image.Image:
    """Ombre portée suivant la silhouette du device (plus réaliste qu'un rectangle)."""
    alpha = device.split()[3]
    pad = blur_px * 2
    w, h = device.size
    layer = Image.new("RGBA", (w + pad * 2, h + pad * 2), (0, 0, 0, 0))
    mask = alpha.point(lambda p: min(255, int(p * opacity / 255)))
    shadow = Image.new("RGBA", device.size, (0, 0, 0, 255))
    shadow.putalpha(mask)
    shadow = shadow.filter(ImageFilter.GaussianBlur(blur_px))
    layer.paste(shadow, (pad + offset[0], pad + offset[1]))
    return layer


def device_shadow(size: tuple[int, int], radius: int, blur_px: int, opacity: int, offset: tuple[int, int]) -> Image.Image:
    w, h = size
    layer = Image.new("RGBA", (w + blur_px * 4, h + blur_px * 4), (0, 0, 0, 0))
    draw = ImageDraw.Draw(layer)
    ox, oy = blur_px * 2 + offset[0], blur_px * 2 + offset[1]
    draw.rounded_rectangle((ox, oy, ox + w - 1, oy + h - 1), radius=radius, fill=(0, 0, 0, opacity))
    return layer.filter(ImageFilter.GaussianBlur(blur_px))


def laptop_frame(screen: Image.Image, *, screen_w: int = 980) -> Image.Image:
    """MacBook simplifié (écran + base)."""
    scale = screen_w / screen.width
    screen_img = screen.resize((screen_w, int(screen.height * scale)), Image.Resampling.LANCZOS)
    sw, sh = screen_img.size
    bezel = 14
    chin = 18
    base_h = 26
    radius = 18
    total_w = sw + bezel * 2
    total_h = sh + bezel + chin + base_h

    frame = Image.new("RGBA", (total_w, total_h), (0, 0, 0, 0))
    draw = ImageDraw.Draw(frame)
    draw.rounded_rectangle((0, 0, total_w - 1, sh + bezel + chin), radius=radius, fill=(32, 34, 38, 255))
    draw.rounded_rectangle((bezel - 4, bezel - 4, total_w - bezel + 3, sh + bezel + 3), radius=12, fill=(12, 12, 14, 255))
    frame.paste(screen_img, (bezel, bezel))

    base_y = sh + bezel + chin
    draw.rounded_rectangle((total_w * 0.12, base_y, total_w * 0.88, base_y + base_h), radius=8, fill=(210, 212, 218, 255))
    draw.rounded_rectangle((total_w * 0.12, base_y, total_w * 0.88, base_y + 6), radius=8, fill=(175, 178, 186, 255))
    notch_w = 90
    draw.rounded_rectangle(
        ((total_w - notch_w) // 2, 6, (total_w + notch_w) // 2, 16),
        radius=6,
        fill=(8, 8, 10, 255),
    )
    return frame


def phone_frame(screen: Image.Image, *, screen_w: int = 250) -> Image.Image:
    scale = screen_w / screen.width
    screen_img = screen.resize((screen_w, int(screen.height * scale)), Image.Resampling.LANCZOS)
    if screen_img.height > 500:
        screen_img = screen_img.crop((0, 0, screen_img.width, 500))
    sw, sh = screen_img.size
    pad = 14
    frame = Image.new("RGBA", (sw + pad * 2, sh + pad * 2 + 8), (0, 0, 0, 0))
    draw = ImageDraw.Draw(frame)
    draw.rounded_rectangle((0, 0, sw + pad * 2 - 1, sh + pad * 2 + 7), radius=34, fill=(20, 22, 26, 255))
    draw.rounded_rectangle((6, 6, sw + pad * 2 - 7, sh + pad * 2 + 1), radius=28, fill=(6, 6, 8, 255))
    notch_w = 72
    draw.rounded_rectangle(
        ((sw + pad * 2 - notch_w) // 2, 10, (sw + pad * 2 + notch_w) // 2, 22),
        radius=8,
        fill=(28, 28, 32, 255),
    )
    frame.paste(screen_img, (pad, pad + 4))
    return frame


def imac_frame(screen: Image.Image, *, screen_w: int = 1080) -> Image.Image:
    """iMac simplifié : écran fin, menton argenté, pied."""
    scale = screen_w / screen.width
    screen_img = screen.resize((screen_w, int(screen.height * scale)), Image.Resampling.LANCZOS)
    sw, sh = screen_img.size

    bezel_x, bezel_top = 10, 10
    chin_h = 18
    radius = 10
    silver = (210, 214, 220, 255)
    silver_dark = (175, 180, 188, 255)
    black = (8, 8, 10, 255)

    display_w = sw + bezel_x * 2
    display_h = sh + bezel_top + chin_h
    stand_neck_h = 42
    stand_base_h = 14
    stand_neck_w = int(display_w * 0.14)
    stand_base_w = int(display_w * 0.28)
    total_h = display_h + stand_neck_h + stand_base_h + 8
    total_w = display_w

    frame = Image.new("RGBA", (total_w, total_h), (0, 0, 0, 0))
    draw = ImageDraw.Draw(frame)

    draw.rounded_rectangle((0, 0, display_w - 1, display_h - 1), radius=radius, fill=silver)
    draw.rounded_rectangle((3, 3, display_w - 4, display_h - 4), radius=radius - 2, fill=silver_dark)
    draw.rounded_rectangle(
        (bezel_x, bezel_top, bezel_x + sw - 1, bezel_top + sh - 1),
        radius=4,
        fill=black,
    )
    frame.paste(screen_img, (bezel_x, bezel_top))

    chin_y = bezel_top + sh
    draw.rectangle((bezel_x, chin_y, display_w - bezel_x, chin_y + chin_h), fill=silver)

    neck_x = (display_w - stand_neck_w) // 2
    neck_y = display_h
    draw.rounded_rectangle(
        (neck_x, neck_y, neck_x + stand_neck_w, neck_y + stand_neck_h),
        radius=4,
        fill=silver_dark,
    )
    base_x = (display_w - stand_base_w) // 2
    base_y = neck_y + stand_neck_h
    draw.rounded_rectangle(
        (base_x, base_y, base_x + stand_base_w, base_y + stand_base_h),
        radius=6,
        fill=silver,
    )
    return frame


def iphone_frame(screen: Image.Image, *, screen_w: int = 270) -> Image.Image:
    """iPhone simplifié (Dynamic Island)."""
    scale = screen_w / screen.width
    screen_img = screen.resize((screen_w, int(screen.height * scale)), Image.Resampling.LANCZOS)
    max_h = 540
    if screen_img.height > max_h:
        screen_img = screen_img.crop((0, 0, screen_img.width, max_h))
    sw, sh = screen_img.size
    pad_x, pad_y = 12, 12
    frame_w, frame_h = sw + pad_x * 2, sh + pad_y * 2 + 6
    titanium = (58, 58, 62, 255)
    titanium_edge = (38, 38, 42, 255)

    frame = Image.new("RGBA", (frame_w, frame_h), (0, 0, 0, 0))
    draw = ImageDraw.Draw(frame)
    draw.rounded_rectangle((0, 0, frame_w - 1, frame_h - 1), radius=38, fill=titanium)
    draw.rounded_rectangle((2, 2, frame_w - 3, frame_h - 3), radius=36, fill=titanium_edge)
    draw.rounded_rectangle(
        (pad_x, pad_y, pad_x + sw - 1, pad_y + sh - 1),
        radius=28,
        fill=(4, 4, 6, 255),
    )
    island_w, island_h = 78, 22
    ix1 = (frame_w - island_w) // 2
    draw.rounded_rectangle((ix1, pad_y + 8, ix1 + island_w, pad_y + 8 + island_h), radius=11, fill=(12, 12, 14, 255))
    frame.paste(screen_img, (pad_x, pad_y))
    return frame


def compose_imac_iphone_featured(
    desktop: Image.Image,
    mobile: Image.Image,
    *,
    canvas_size: tuple[int, int] = FEATURED_SIZE,
) -> Image.Image:
    """Navigateur desktop + iPhone (référence mockup) sur dégradé charte — sans texte."""
    cw, ch = canvas_size
    canvas = mesh_gradient(cw, ch, FEATURED_PALETTE).convert("RGBA")

    browser_w = int(cw * 0.64)
    browser = safari_browser_window(desktop, content_width=browser_w, viewport_ratio=9.5 / 16)

    target_phone_h = int(ch * 0.58)
    if IPHONE_BEZEL.is_file():
        phone = composite_apple_bezel(IPHONE_BEZEL, mobile, inset_px=3)
        phone = scale_rgba(phone, target_phone_h)
    else:
        phone_screen_w = max(260, int(target_phone_h / 2.22))
        phone = mockup_phone_frame(mobile, screen_w=phone_screen_w)

    group_w = browser.width + int(phone.width * 0.42)
    bx = (cw - group_w) // 2
    by = (ch - browser.height) // 2
    px = bx + browser.width - int(phone.width * 0.34)
    py = by + browser.height - int(phone.height * 0.92)

    browser_radius = max(14, int(browser_w * 0.013))
    browser_shadow = drop_shadow(
        browser.size, browser_radius, blur_px=38, opacity=95, offset=(0, 22)
    )
    canvas.alpha_composite(browser_shadow, (bx - 76, by - 58))
    canvas.alpha_composite(browser, (bx, by))

    phone_radius = max(28, int(phone.width * 0.16))
    phone_shadow = drop_shadow(phone.size, phone_radius, blur_px=32, opacity=90, offset=(0, 20))
    canvas.alpha_composite(phone_shadow, (px - 64, py - 48))
    canvas.alpha_composite(phone, (px, py))

    return canvas.convert("RGB")


def compose_portfolio_desktop_featured(
    desktop: Image.Image,
    *,
    canvas_size: tuple[int, int] = FEATURED_SIZE,
    palette: list[tuple[int, int, int]] = FEATURED_PALETTE,
) -> Image.Image:
    """Image à la une — iMac photo-réaliste, capture strictement dans l'écran, sans overlay."""
    cw, ch = canvas_size
    canvas = cinematic_mesh_gradient(cw, ch, palette, accent=ACCENT).convert("RGBA")

    if IMAC_BEZEL.is_file():
        device = composite_apple_bezel(IMAC_BEZEL, desktop, inset_px=5)
        device = scale_rgba(device, int(ch * 0.72))
    else:
        browser_w = int(cw * 0.68)
        device = safari_browser_window(desktop, content_width=browser_w, viewport_ratio=9.5 / 16)

    bx = (cw - device.width) // 2
    by = (ch - device.height) // 2 + int(ch * 0.015)

    pad = 44 * 2
    glow_shadow = tinted_shadow(device, palette[2], blur_px=58, opacity=38, offset=(0, 28))
    canvas.alpha_composite(glow_shadow, (bx - pad - 18, by - pad - 8))
    shadow = silhouette_shadow(device, blur_px=44, opacity=105, offset=(0, 24))
    canvas.alpha_composite(shadow, (bx - pad, by - pad))
    canvas.alpha_composite(device, (bx, by))

    refl = floor_reflection(device, alpha=26)
    canvas.alpha_composite(refl, (bx, by + device.height - 4))

    return canvas.convert("RGB")


def compose_wow_desktop_featured(
    desktop: Image.Image,
    *,
    canvas_size: tuple[int, int] = FEATURED_SIZE,
    palette: list[tuple[int, int, int]] = FEATURED_PALETTE,
    accent: tuple[int, int, int] = ACCENT,
    tilt: float = -0.6,
) -> Image.Image:
    """WOW desktop seul — Safari centré, halo, ombres teintées, reflet sol."""
    cw, ch = canvas_size
    canvas = cinematic_mesh_gradient(cw, ch, palette, accent=accent).convert("RGBA")

    browser_w = int(cw * 0.70)
    browser = safari_browser_window(desktop, content_width=browser_w, viewport_ratio=9.5 / 16)
    if abs(tilt) > 0.01:
        browser = browser.rotate(tilt, resample=Image.Resampling.BICUBIC, expand=True)
    browser = accent_edge_glow(browser, accent)

    bx = (cw - browser.width) // 2
    by = (ch - browser.height) // 2 - int(ch * 0.02)

    browser_radius = max(14, int(browser_w * 0.013))
    shadow = tinted_shadow(browser, accent, blur_px=52, opacity=110, offset=(0, 32))
    shadow2 = drop_shadow(browser.size, browser_radius, blur_px=20, opacity=45, offset=(0, 10))
    canvas.alpha_composite(shadow, (bx - 104, by - 78))
    canvas.alpha_composite(shadow2, (bx - 40, by - 12))
    canvas.alpha_composite(browser, (bx, by))

    refl = floor_reflection(browser, alpha=32)
    canvas.alpha_composite(refl, (bx, by + browser.height - 6))

    return apply_film_grain(canvas.convert("RGB"))


def compose_device_hero(desktop: Image.Image, mobile: Image.Image) -> Image.Image:
    """Alias legacy — délègue au rendu iMac / iPhone sans habillage texte."""
    return compose_imac_iphone_featured(desktop, mobile)


def export_gallery_shot(src: Path, out: Path, *, max_height: int = 1800) -> None:
    """Capture brute pour la galerie / lightbox (sans habillage marketing)."""
    img = Image.open(src).convert("RGB")
    if img.height > max_height:
        img = img.crop((0, 0, img.width, max_height))
    img.save(out, "JPEG", quality=90, optimize=True)


def main() -> None:
    OUT.mkdir(parents=True, exist_ok=True)

    jobs = [
        ("home-desktop.png", "cabinet-hexagone-hero.jpg", "Cabinet Hexagone", "Site vitrine implantologie · Nantes", f"{SITE_URL}/"),
        ("equipe-desktop.png", "cabinet-hexagone-equipe.jpg", "L'équipe", "Présentation des praticiens et du secrétariat", f"{SITE_URL}/lequipe/"),
        ("competences-desktop.png", "cabinet-hexagone-competences.jpg", "Compétences", "Implantologie, parodontologie, chirurgie", f"{SITE_URL}/competences/"),
        ("contact-desktop.png", "cabinet-hexagone-contact.jpg", "Contact & accès", "Formulaire, plan et horaires du cabinet", f"{SITE_URL}/contact/"),
        ("fiches-desktop.png", "cabinet-hexagone-fiches.jpg", "Fiches conseils", "Contenus éditoriaux et FAQ patients", f"{SITE_URL}/fiches-conseils/"),
    ]

    for src_name, out_name, title, subtitle, url in jobs:
        src = SHOTS / src_name
        if not src.exists():
            raise FileNotFoundError(src)
        shot = crop_viewport(Image.open(src).convert("RGB"), 1300)
        mock = compose_showcase(shot, title=title, subtitle=subtitle, url=url)
        out = OUT / out_name
        mock.save(out, "JPEG", quality=90, optimize=True)
        print(f"saved {out.name} ({out.stat().st_size // 1024} KB)")

    mobile_src = SHOTS / "home-mobile-viewport.png"
    if not mobile_src.exists():
        mobile_src = SHOTS / "home-mobile-viewport.png"
    mobile = phone_mockup(Image.open(mobile_src).convert("RGB"), title="Cabinet Hexagone")
    mobile_out = OUT / "cabinet-hexagone-mobile.jpg"
    mobile.save(mobile_out, "JPEG", quality=90, optimize=True)
    print(f"saved {mobile_out.name} ({mobile_out.stat().st_size // 1024} KB)")

    # Image à la une portfolio : iMac + capture desktop, fond charte, sans habillage
    desktop_path = SHOTS / "home-desktop-hd.png"
    if not desktop_path.exists():
        desktop_path = SHOTS / "home-desktop.png"
    desktop_shot = Image.open(desktop_path).convert("RGB")
    featured = compose_portfolio_desktop_featured(desktop_shot)

    featured_out = OUT / "cabinet-hexagone-featured.jpg"
    featured.save(featured_out, "JPEG", quality=93, optimize=True, subsampling=0)
    print(f"saved {featured_out.name} {featured.size} ({featured_out.stat().st_size // 1024} KB)")

    thumb = featured.resize(THUMB_SIZE, Image.Resampling.LANCZOS)
    thumb_out = OUT / "cabinet-hexagone-thumb.jpg"
    thumb.save(thumb_out, "JPEG", quality=90, optimize=True)
    print(f"saved {thumb_out.name} {thumb.size}")

    large = featured.resize((1280, 853), Image.Resampling.LANCZOS)
    large_out = OUT / "cabinet-hexagone-featured-1280.jpg"
    large.save(large_out, "JPEG", quality=92, optimize=True)
    print(f"saved {large_out.name} (WP large)")

    gallery_dir = OUT / "gallery"
    gallery_dir.mkdir(exist_ok=True)
    gallery_jobs = [
        ("home-desktop.png", "cabinet-hexagone-accueil.jpg", 1700),
        ("equipe-desktop.png", "cabinet-hexagone-equipe-shot.jpg", 1700),
        ("competences-desktop.png", "cabinet-hexagone-competences-shot.jpg", 1700),
        ("contact-desktop.png", "cabinet-hexagone-contact-shot.jpg", 1700),
        ("fiches-desktop.png", "cabinet-hexagone-fiches-shot.jpg", 1700),
        ("home-mobile-full.png", "cabinet-hexagone-mobile-shot.jpg", 3200),
    ]
    for src_name, out_name, max_h in gallery_jobs:
        src = SHOTS / src_name
        if not src.exists() and src_name == "home-mobile-full.png":
            src = SHOTS / "home-mobile-viewport.png"
        if not src.exists():
            src = SHOTS / "home-mobile-viewport.png"
        export_gallery_shot(src, gallery_dir / out_name, max_height=max_h)
        print(f"saved gallery/{out_name}")


if __name__ == "__main__":
    main()
