"""Phantomline thumbnail generator.

YouTube CTR is title × thumbnail. The product generates everything else;
this module closes the gap.

Two paths:

1. **Forge SDXL** when available (desktop install with Forge running on
   localhost:7861, or any A1111-compatible endpoint). Generates a
   cinematic background image tuned for thumbnail use cases.

2. **PIL-only fallback** when Forge isn't reachable (the hosted Render
   slim deploy, or a desktop user who hasn't set up Forge yet).

Both paths end in `compose_thumbnail()` which lays the title text on top.
The composition layer changes per preset.

Design grounded in 2026 research (see docs/thumbnail-research-2026.md):

- 1of10 study (300k high-performing 2025 videos): thumbnails with text
  averaged ~19% fewer views; cyan-dominant thumbnails averaged +36%;
  dark thumbnails underperformed; faces are not a universal win.
  Correlational vendor data, treated as directional. Net product
  decision: default `text_overlay=False` for non-listicle presets.

- YouTube's native A/B tool optimizes for watch-time share, not CTR.
  Variants under 720p get the entire test downscaled to 480p, so we
  never output below 720p. Recommended upload size in 2026 is 3840×2160
  uploaded as-large-as-possible — we generate at 1920×1080 (Forge's
  reliable SDXL ceiling at standard checkpoints) and the caller can
  upscale before upload if they want max-spec output.

- "AI penalty" is not platform-policy; it's audience trust. Avoid the
  Midjourney-monster-portrait look — every preset's negative prompt
  includes the visual tells (glossy skin, floating embers, fake
  volumetric fog) called out in the research.
"""

from __future__ import annotations

import base64
import math
import os
import random
import re
from io import BytesIO
from pathlib import Path

import requests
from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFilter, ImageFont


# ---------------------------------------------------------------------------
# Bundled fonts. These ship in static/fonts/ (committed to git) so the
# generator gets consistent typography on every machine — including the
# hosted Render slim deploy where OS-default Impact may not exist.
# ---------------------------------------------------------------------------
_FONTS_DIR = Path(__file__).resolve().parent / "static" / "fonts"

# Per-niche font choice. Picked so each preset has a recognizable voice:
#  - Anton: tall condensed sans, the modern "explainer big text" face
#  - BebasNeue: classic editorial poster, works for story/horror titles
#  - Bangers: comic-book display, the listicle hero face
#  - MontserratBlack: heavy round sans, used for tutorial captions
#  - Inter: subtitle / eyebrow / metadata
_FONT_FILES = {
    "anton": _FONTS_DIR / "Anton-Regular.ttf",
    "bebas": _FONTS_DIR / "BebasNeue-Regular.ttf",
    "bangers": _FONTS_DIR / "Bangers-Regular.ttf",
    "montserrat_black": _FONTS_DIR / "Montserrat-Black.ttf",
    "inter": _FONTS_DIR / "Inter-Bold.ttf",
}


# YouTube 2026 spec: 16:9 thumbnails uploaded at 3840×2160 ideal, with a
# minimum width of 640 px. We generate at 1920×1080 (SDXL-friendly,
# safely above the 720p A/B testing floor) and let the caller upscale
# to 4K before upload if needed. 9:16 (Shorts feed) and 4:5 (mobile
# Home/Explore replacement for vertical videos) are also supported.
THUMBNAIL_SIZES = {
    "16:9": (1920, 1080),
    "9:16": (1080, 1920),
    "4:5": (1080, 1350),
    "1:1": (1080, 1080),
}

# Bottom-right safe-zone — YouTube overlays the video duration here at
# render time. Anything in this corner gets clobbered. Pixels are
# percentages of canvas height/width.
SAFE_ZONE_BOTTOM_RIGHT = (0.18, 0.10)  # 18% width, 10% height kept clear

# Phantomline brand fallbacks for when no preset matches.
BRAND_BG = (9, 11, 12)            # #090b0c — site theme-color
BRAND_ACCENT = (52, 211, 219)     # phantomline cyan
TEXT_PRIMARY = (255, 255, 255)
TEXT_MUTED = (180, 195, 200)


# ---------------------------------------------------------------------------
# Post-processing pipeline. Applied to every AI-generated background
# before the composition layer overlays text. The goal is the
# "professional 4K polish" finish that ChatGPT thumbnails have over
# raw FLUX/SDXL output: punchier colors, sharper edges, slight contrast
# bump, subtle vignette. The values are tuned conservatively — too much
# saturation pushes thumbnails into "AI obvious" territory.
# ---------------------------------------------------------------------------


def post_process_background(img_bytes: bytes, *, preset_name: str = "faceless_story",
                            sharpen: float = 1.35,
                            contrast: float = 1.15,
                            saturation: float = 1.18,
                            vignette_strength: float = 0.18,
                            denoise: bool = True) -> bytes:
    """Apply the premium polish pass: subtle bilateral-style denoise +
    unsharp mask + contrast/saturation grade + soft vignette. Returns
    PNG bytes. Idempotent if you pass the same input.

    The defaults aim for the "Magnific-lite" look — clean smooth low-detail
    areas, crisp high-detail edges, rich-but-not-crushed color, gentle
    eye-pull vignette. Stronger than the original tuning because AI image
    backends (Pollinations FLUX) ship slightly soft and undersaturated
    out of the box, and we have to compensate before text overlays."""
    with Image.open(BytesIO(img_bytes)) as img:
        img = img.convert("RGB")
        width, height = img.size

        # Step 1: gentle denoise on smooth areas BEFORE sharpening.
        # MedianFilter with a small radius softens flat AI-noise patches
        # (sky, walls, fog) without erasing edge detail. We then sharpen
        # to bring back micro-contrast on the edges that survived. This
        # is the "smooth then sharpen" trick that Topaz / Magnific do
        # internally — denoise hides AI smudge, sharpen restores feel.
        if denoise:
            soft = img.filter(ImageFilter.MedianFilter(size=3))
            # Edge mask: where the image has high local contrast, keep
            # the original (sharp) pixels; where it's flat, use the soft
            # version. PIL's FIND_EDGES gives us the mask cheaply.
            edges = img.convert("L").filter(ImageFilter.FIND_EDGES)
            edges = edges.filter(ImageFilter.GaussianBlur(2))
            # Boost edge mask so even mid-strength edges keep sharp data.
            edges = ImageEnhance.Contrast(edges).enhance(2.0)
            img = Image.composite(img, soft, edges)

        # Step 2: unsharp mask — adds local edge contrast without the
        # crispy oversharpening look from .SHARPEN. Stronger settings
        # than before (1.35 vs 1.18) because the denoise step softens
        # things first.
        if sharpen and sharpen != 1.0:
            img = img.filter(
                ImageFilter.UnsharpMask(radius=1.8, percent=int((sharpen - 1.0) * 200), threshold=2)
            )

        if contrast and contrast != 1.0:
            img = ImageEnhance.Contrast(img).enhance(contrast)

        if saturation and saturation != 1.0:
            img = ImageEnhance.Color(img).enhance(saturation)

        # Step 3: subtle vignette pulls the eye to center.
        if vignette_strength > 0:
            mask = Image.new("L", (width, height), 0)
            mdraw = ImageDraw.Draw(mask)
            cx, cy = width // 2, height // 2
            max_r = int(math.hypot(width, height) * 0.55)
            for r in range(max_r, 0, -8):
                t = 1.0 - (r / max_r)
                mdraw.ellipse(
                    (cx - r, cy - r, cx + r, cy + r),
                    fill=int(255 * (1 - t * vignette_strength)),
                )
            mask = mask.filter(ImageFilter.GaussianBlur(40))
            dim = Image.new("RGB", (width, height), (0, 0, 0))
            img = Image.composite(img, dim, mask)

        out = BytesIO()
        img.save(out, "PNG", optimize=True)
        return out.getvalue()


def upscale_to_4k(img_bytes: bytes, target_long_edge: int = 3840) -> bytes:
    """LANCZOS upscale + edge-preserving sharpening to push the final
    composite toward YouTube's recommended 3840×2160 spec.

    LANCZOS alone produces clean smooth upscaling. We follow with a mild
    unsharp mask on the LARGE image to restore the perceived detail that
    bilinear interpolation softens out. End result is closer to a true
    4K capture than a stretched 1080p still."""
    with Image.open(BytesIO(img_bytes)) as img:
        img = img.convert("RGB")
        w, h = img.size
        long_edge = max(w, h)
        if long_edge >= target_long_edge:
            # Already 4K or larger — no work needed.
            out = BytesIO()
            img.save(out, "PNG", optimize=True)
            return out.getvalue()
        scale = target_long_edge / long_edge
        new_w = int(round(w * scale))
        new_h = int(round(h * scale))
        # LANCZOS = highest-quality resampling PIL ships. Slow at 4K but
        # we only do this once per thumbnail.
        img = img.resize((new_w, new_h), Image.LANCZOS)
        # Mild post-upscale sharpen to claw back perceived detail.
        img = img.filter(ImageFilter.UnsharpMask(radius=1.2, percent=80, threshold=3))
        out = BytesIO()
        img.save(out, "PNG", optimize=True)
        return out.getvalue()


# ---------------------------------------------------------------------------
# Per-niche presets. Wired from the 2026 deep-research findings.
#
# Each preset describes:
#   palette:              5-color hex sequence (most→least dominant)
#   accent:               hot accent color (RGB tuple) used for the
#                         underline / eyebrow / number block
#   bg:                   default canvas color when no AI image is
#                         generated (PIL fallback path)
#   subject_placement:    English description for SDXL prompt
#   composition_inject:   atmosphere + texture cues for SDXL prompt
#   negative_inject:      AI-tells to keep out of the image
#   text_default:         whether text-overlay is on by default
#                         (False for everything except listicle, per the
#                         1of10 -19% text-thumbnail finding)
#   text_max_words:       hard ceiling so a generator caller can't
#                         accidentally request 12-word essay overlays
#   text_position:        anchor for the optional overlay
# ---------------------------------------------------------------------------

THUMBNAIL_PRESETS = {
    "faceless_story": {
        "label": "Faceless story / Reddit narration (MrBallen-style)",
        "palette": [
            (19, 25, 35), (157, 30, 37), (231, 228, 221),
            (91, 201, 214), (52, 65, 75),
        ],
        "accent": (91, 201, 214),
        "secondary": (157, 30, 37),
        "bg": (19, 25, 35),
        "fallback_pattern": "rim_light",   # PIL fallback layout
        "title_font": "bebas",
        "subtitle_font": "inter",
        "subject_placement": (
            "witness silhouette, narrator stand-in, or reaction cutout on "
            "the left third; evidence object, location, or single anomaly "
            "on the right third"
        ),
        "composition_inject": (
            "documentary still, forensic flash photography, CCTV softness, "
            "one evidence object, analog grain, slight chromatic aberration, "
            "imperfect symmetry, scuffed surfaces, real focal length, "
            "muted cinematic grading"
        ),
        "negative_inject": (
            "crime-board collage, multiple photos arranged, fake newspaper "
            "scraps, glowing evidence, aggressive HDR, glossy skin, "
            "floating embers, fake volumetric fog, posterized true crime"
        ),
        "text_default": False,
        "text_max_words": 5,
        "text_position": "lower-left",
        # Auto-applied badges that give the thumbnail the polished
        # poster look (BEDTIME-STORY / EREBUS reference style). Caller
        # can override via API params; these are the defaults.
        "genre_badge": "BASED ON TRUE EVENTS",
        "feature_tag_defaults": ["ORIGINAL STORY", "FULL EPISODE"],
    },
    "horror_cosmic": {
        "label": "Horror / cosmic horror narration",
        "palette": [
            (7, 9, 13), (16, 34, 53), (49, 215, 255),
            (216, 212, 198), (97, 42, 66),
        ],
        "accent": (49, 215, 255),
        "secondary": (216, 212, 198),
        "bg": (7, 9, 13),
        "fallback_pattern": "spotlight",
        "title_font": "bebas",
        "subtitle_font": "inter",
        "subject_placement": (
            "anomaly centered with massive scale; tiny human or human-scale "
            "object placed lower-third for size reference"
        ),
        "composition_inject": (
            "analog horror still, motivated light source like monitor glow "
            "or lunar rim light or lab green or lighthouse beam, single "
            "anomaly, environmental scale, film grain, sensor noise, "
            "weathering, imperfect focus, real-world prop wear, large "
            "negative space, documentary evidence aesthetic"
        ),
        "negative_inject": (
            "beautiful monster portrait, center-framed creature against "
            "purple smoke, Midjourney horror wallpaper, glossy skin, "
            "fake volumetric fog, floating embers, generic SDXL fog"
        ),
        "text_default": False,
        "text_max_words": 5,
        "text_position": "lower-center",
        "genre_badge": "ANALOG HORROR",
        "feature_tag_defaults": ["NARRATED STORY"],
    },
    "mystery_documentary": {
        "label": "Mystery documentary / true-crime adjacent",
        "palette": [
            (20, 21, 24), (199, 185, 160), (178, 30, 35),
            (236, 231, 220), (104, 122, 138),
        ],
        "accent": (178, 30, 35),
        "secondary": (199, 185, 160),
        "bg": (20, 21, 24),
        "fallback_pattern": "dossier",
        "title_font": "bebas",
        "subtitle_font": "inter",
        "subject_placement": (
            "single artifact (mugshot, VHS still, map, poster, house, or "
            "symbol) center or right; if a face is needed it is the case "
            "subject's face, never the creator's"
        ),
        "composition_inject": (
            "xerox texture, halftone print, aged paper, camera flash, "
            "timestamp overlay aesthetic, era-accurate props, mild "
            "chromatic bleeding, partial occlusion, dossier photography, "
            "evidence-room lighting"
        ),
        "negative_inject": (
            "fake detective board, fingerprint overlays, blood splatter "
            "decoration, six photos arranged, generic SDXL collage, "
            "glossy professional photography, AI-perfect symmetry"
        ),
        "text_default": False,
        "text_max_words": 4,
        "text_position": "lower-left",
        "genre_badge": "TRUE CRIME",
        "feature_tag_defaults": ["FULL CASE"],
    },
    "tutorial_explainer": {
        "label": "Tutorial / how-to / explainer (Fireship-style)",
        "palette": [
            (16, 19, 24), (255, 107, 61), (237, 237, 237),
            (86, 196, 255), (35, 42, 54),
        ],
        "accent": (255, 107, 61),
        "secondary": (86, 196, 255),
        "bg": (16, 19, 24),
        "fallback_pattern": "diagonal_block",
        "title_font": "anton",
        "subtitle_font": "montserrat_black",
        "subject_placement": (
            "one product fragment, UI crop, or rendered prop center or "
            "center-right; supporting human face or icon on the opposite "
            "side ONLY if the conflict requires a reaction"
        ),
        "composition_inject": (
            "clean studio lighting, single product shot, flat-but-deep "
            "background gradient, real screenshot composited cleanly, "
            "high color contrast on a dark slate base, before-and-after "
            "transformation reads left to right"
        ),
        "negative_inject": (
            "full desktop screenshots, three windows, code panes with "
            "unreadable text, fake glossy 3D dashboards, generic SDXL "
            "interface mockups, tab clutter, sticker bombing"
        ),
        "text_default": True,
        "text_max_words": 5,
        "text_position": "left-center",
        "genre_badge": "TUTORIAL",
        "feature_tag_defaults": [],
    },
    "listicle": {
        "label": "Listicle / Top N (text-as-hero exception)",
        "palette": [
            (12, 12, 14), (198, 31, 38), (244, 241, 235),
            (226, 185, 59), (44, 90, 99),
        ],
        "accent": (198, 31, 38),
        "secondary": (226, 185, 59),
        "bg": (12, 12, 14),
        "fallback_pattern": "poster",
        "title_font": "bangers",
        "subtitle_font": "anton",
        "subject_placement": (
            "giant count or topic block on the center-left or full center; "
            "one hero concept image cropped on the right edge"
        ),
        "composition_inject": (
            "one textured background or one hero object only, distressed "
            "paper or scratched film texture, single high-contrast subject, "
            "negative space for typography overlay, magazine-poster aesthetic"
        ),
        "negative_inject": (
            "ten arrows, ten little circles, ten cutouts, scrapbook layout, "
            "AI-generated pseudo-letters, muddy text edges, generic SDXL "
            "infographic, cluttered composition"
        ),
        "text_default": True,
        "text_max_words": 6,
        "text_position": "center",
        "genre_badge": "TOP LIST",
        "feature_tag_defaults": [],
    },
}


def detect_preset(title: str, genre: str = "", recipe: str = "") -> str:
    """Best-effort preset auto-detection from title/genre/recipe strings.

    Returns one of the THUMBNAIL_PRESETS keys, defaulting to faceless_story
    (the broadest catch-all for narration content). Caller can override
    by passing `style=<preset_key>` directly to the API."""
    blob = f"{title} {genre} {recipe}".lower()

    # Listicle detection wins first because the count is the strongest
    # signal — "10 disturbing X" should not get classified as story even
    # if "disturbing" matches horror keywords.
    if re.search(r"\btop\s*\d+\b|\b\d{1,3}\s+(disturbing|creepy|terrifying|unexplained|mysterious|haunting|scariest|weirdest|strangest|dark)\b", blob):
        return "listicle"
    if "listicle" in blob or re.match(r"^\d{1,3}\s", blob):
        return "listicle"

    # Genre/recipe explicit overrides — if the upstream form said
    # "cosmic horror" or "documentary", that's a more reliable signal
    # than title-pattern guessing, so honor it before heuristics.
    explicit = f"{genre} {recipe}".lower()
    if "horror" in explicit or "cosmic" in explicit or "creepypasta" in explicit:
        return "horror_cosmic"
    if "documentary" in explicit or "true crime" in explicit or "investigation" in explicit:
        return "mystery_documentary"
    if "tutorial" in explicit or "explainer" in explicit or "how-to" in explicit:
        return "tutorial_explainer"
    if "listicle" in explicit:
        return "listicle"

    # Tutorial detection — but only on instructional-tense markers.
    # "Explained" / "explainer" alone is too noisy (MrBallen titles end
    # with "Explained" and they are not tutorials), so we require a
    # stronger signal: "how to", "how-to", "guide", etc.
    if "tutorial" in blob or "how to" in blob or "how-to" in blob or " guide" in blob or "step by step" in blob or "setup" in blob or "set up" in blob:
        return "tutorial_explainer"

    # Faceless-story signals — "found footage" / "I work in" / "I
    # checked" patterns are unambiguously story narration.
    if "found footage" in blob or "i work" in blob or "found out" in blob or "creepy story" in blob or "reddit" in blob or "scary story" in blob:
        return "faceless_story"

    if "horror" in blob or "cosmic" in blob or "eldritch" in blob or "backrooms" in blob or "analog horror" in blob or "creature" in blob or "monster" in blob or "creepypasta" in blob or "lighthouse" in blob or "abyss" in blob:
        return "horror_cosmic"

    # Use "myster" stem so both "mystery" and "mysteries" match. Same
    # for "documentar"/"investigat".
    if "documentar" in blob or "true crime" in blob or "case file" in blob or "investigat" in blob or "unsolved" in blob or "myster" in blob:
        return "mystery_documentary"

    return "faceless_story"


def get_preset(name: str | None) -> dict:
    """Resolve a preset name to its dict. Falls back to faceless_story."""
    if not name or name == "auto":
        return THUMBNAIL_PRESETS["faceless_story"]
    return THUMBNAIL_PRESETS.get(name, THUMBNAIL_PRESETS["faceless_story"])


# ---------------------------------------------------------------------------
# Forge prompt construction
# ---------------------------------------------------------------------------


def forge_thumbnail_prompt(title: str, preset_name: str = "faceless_story",
                           subject_hint: str = "") -> tuple[str, str]:
    """Build (positive_prompt, negative_prompt) tuned to a preset.

    The positive prompt composes: [title concept] + [subject placement
    rule] + [composition_inject from preset] + [universal thumbnail
    quality cues]. The negative prompt prepends preset-specific AI-tells
    to avoid (per 2026 research) on top of universal quality killers.
    """
    preset = get_preset(preset_name)
    parts = [title.strip()]
    if subject_hint.strip():
        parts.insert(0, subject_hint.strip())
    parts.extend([
        preset["subject_placement"],
        preset["composition_inject"],
        # Universal quality cues — apply to every preset.
        "cinematic YouTube thumbnail composition",
        "readable at 120px feed-scroll size",
        "TV-safe contrast ratios",
        "negative space for title overlay",
        "no text, no captions, no logo, no watermark, no UI",
        "8k detail, professional color grading",
    ])
    positive = ", ".join(parts)

    negative_parts = [
        preset["negative_inject"],
        # Universal AI-tells to avoid (from 2026 research).
        "blurry, low quality, watermark, text, caption, logo, distorted, "
        "bad anatomy, ugly, deformed, generic stock art, "
        "obvious AI generation, glossy skin, perfect symmetry, "
        "AI fingers, plastic skin, posterized HDR",
    ]
    negative = ", ".join(negative_parts)

    return positive, negative


# ---------------------------------------------------------------------------
# Forge backend
# ---------------------------------------------------------------------------


def _forge_url() -> str:
    return (
        os.getenv("GHOSTLINE_FORGE_URL")
        or os.getenv("BINDERY_FORGE_URL")
        or "http://127.0.0.1:7861"
    ).rstrip("/")


def _forge_checkpoint() -> str:
    return (
        os.getenv("GHOSTLINE_FORGE_CHECKPOINT")
        or os.getenv("BINDERY_FORGE_CHECKPOINT")
        or ""
    ).strip()


def forge_available(timeout: float = 1.5) -> bool:
    """Liveness check against Forge sd-webui API."""
    try:
        res = requests.get(f"{_forge_url()}/sdapi/v1/sd-models", timeout=timeout)
        return res.status_code == 200
    except requests.RequestException:
        return False


def generate_forge_background(title: str, *, aspect: str = "16:9",
                              preset_name: str = "faceless_story",
                              subject_hint: str = "",
                              steps: int = 22) -> bytes:
    """Hit Forge's txt2img with a preset-tuned prompt. Returns raw PNG bytes.

    Generation resolution is bin-aligned for SDXL (1024-base) and then
    upscaled to thumbnail size with LANCZOS. Going straight to 1920×1080
    in SDXL triggers framing artifacts at non-trained ratios."""
    width, height = THUMBNAIL_SIZES.get(aspect, THUMBNAIL_SIZES["16:9"])

    if aspect == "16:9":
        gen_w, gen_h = 1280, 720
    elif aspect == "9:16":
        gen_w, gen_h = 720, 1280
    elif aspect == "4:5":
        gen_w, gen_h = 832, 1040
    else:
        gen_w, gen_h = 1024, 1024

    positive, negative = forge_thumbnail_prompt(title, preset_name=preset_name,
                                                subject_hint=subject_hint)

    payload = {
        "prompt": positive,
        "negative_prompt": negative,
        "steps": int(max(8, min(40, steps))),
        "width": gen_w,
        "height": gen_h,
        "cfg_scale": 1.0,
        "distilled_cfg_scale": 3.5,
        "sampler_name": "Euler",
        "sampler_index": "Euler",
        "scheduler": "Simple",
        "batch_size": 1,
        "n_iter": 1,
        "seed": -1,
        "do_not_save_samples": True,
        "do_not_save_grid": True,
    }
    cp = _forge_checkpoint()
    if cp:
        payload["override_settings"] = {"sd_model_checkpoint": cp}
        payload["override_settings_restore_afterwards"] = True

    res = requests.post(f"{_forge_url()}/sdapi/v1/txt2img", json=payload, timeout=600)
    if res.status_code >= 400:
        raise RuntimeError(f"Forge txt2img failed: HTTP {res.status_code} — {res.text[:200]}")
    try:
        data = res.json()
    except ValueError as exc:
        raise RuntimeError(f"Forge returned non-JSON: {res.text[:200]}") from exc
    images = data.get("images") or []
    if not images:
        raise RuntimeError(f"Forge returned no images: {data.get('error') or data!r}")
    raw = base64.b64decode(images[0])
    with Image.open(BytesIO(raw)) as img:
        img = img.convert("RGB").resize((width, height), Image.LANCZOS)
        out = BytesIO()
        img.save(out, "PNG", optimize=True)
        return out.getvalue()


# ---------------------------------------------------------------------------
# Pollinations.ai — FREE FLUX endpoint. No API key. No auth. The community
# runs FLUX-schnell behind a public URL pattern:
#
#   https://image.pollinations.ai/prompt/{url_encoded_prompt}?width=...&height=...&model=flux&seed=...&nologo=true
#
# Trade-off: it's a free service, so reliability isn't guaranteed —
# response times vary (3-15s typical, 30s+ during peak), and outages
# happen. This is why we still chain to fal.ai (paid, reliable) → PIL
# (always works). For Phantomline at low-medium volume, Pollinations is
# the right primary backend.
# ---------------------------------------------------------------------------

_POLLINATIONS_BASE = "https://image.pollinations.ai/prompt/"

# Pollinations model priority. Try the highest-quality endpoint first
# and fall through on HTTP failure / quota.
#   - gptimage:      OpenAI's GPT-Image-1 mirror — best polish/coherence
#                    when capacity is available. Falls through hard on
#                    quota, so flux-realism backs it up.
#   - flux-realism:  FLUX with extra photography fine-tuning. Materially
#                    better thumbnail imagery than vanilla flux.
#   - flux:          FLUX-schnell, always available, decent baseline.
#   - turbo:         Last resort, fastest but lowest quality.
_POLLINATIONS_MODEL_PRIORITY = ["gptimage", "flux-realism", "flux", "turbo"]


def pollinations_available() -> bool:
    """Pollinations is always available unless explicitly disabled."""
    return os.getenv("PHANTOMLINE_DISABLE_POLLINATIONS", "").lower() not in ("1", "true", "yes")


# ---------------------------------------------------------------------------
# Ollama-driven prompt enhancement.
#
# When a user types "EREBUS detective evidence wall" and ChatGPT silently
# turns it into a 70-word cinematography-rich description before sending
# to DALL-E, that's why ChatGPT thumbnails look more polished than ours
# from the same raw prompt. This function does that step locally with
# llama3.1 — no paid API. The result feeds into Pollinations/Forge as a
# much richer prompt and the output quality jumps materially.
#
# We *only* enhance when local Ollama is reachable. If it's not, we
# fall through to the raw prompt — quality is still fine, just less
# cinematic.
# ---------------------------------------------------------------------------

_ENHANCE_SYSTEM_PROMPT = (
    "You are a professional cinematographer + YouTube thumbnail art director. "
    "Given a thumbnail concept, output ONE dense 50-90 word visual description "
    "for an AI image generator. Include: camera and lens (e.g. Arri Alexa 35, "
    "anamorphic, 35mm), lighting (motivated source, time of day, color "
    "temperature), atmosphere (dust, fog, rain, particles), composition "
    "(framing, depth, focal point, negative space for title overlay), color "
    "grading (palette in concrete terms — teal/orange, Kodak Portra, etc.), "
    "and texture (film grain, subtle imperfections). Output the description "
    "ONLY — no preface, no bullet points, no quotes, no markdown. Plain prose. "
    "Never include any text/letters/words/captions IN the image — they are "
    "composited on top later."
)


def _ollama_reachable() -> bool:
    """Quick liveness check. Reuses story_generator's tags endpoint."""
    try:
        import story_generator as _sg
        return _sg.check_ollama() is not None
    except Exception:
        return False


def enhance_prompt_with_ollama(title: str, *, preset_name: str = "faceless_story",
                               subject_hint: str = "",
                               genre: str = "",
                               model: str = "llama3.1",
                               timeout: float = 30.0) -> str | None:
    """Use a local Ollama model to expand a thumbnail concept into a
    cinematography-rich prompt. Returns the enhanced prompt string, or
    None if Ollama is unreachable / the model fails.

    The output replaces the raw `forge_thumbnail_prompt` positive output
    when the AI image backend runs — but the negative prompt is still
    appended downstream, so we don't need to enhance that too.
    """
    if not _ollama_reachable():
        return None
    try:
        import story_generator as _sg
    except Exception:
        return None

    user_prompt = _build_enhance_user_prompt(title, preset_name, subject_hint, genre=genre)

    try:
        raw = _sg.generate(
            model,
            user_prompt,
            system=_ENHANCE_SYSTEM_PROMPT,
            label="enhance",
            show_progress=False,
            temperature=0.65,
            num_predict=240,
        )
    except Exception:
        return None

    text = (raw or "").strip()
    if not text:
        return None
    # Strip common model meta artifacts.
    text = re.sub(r"^(here'?s?|sure|okay)[,:\s]+.*?$", "", text, flags=re.IGNORECASE | re.MULTILINE).strip()
    text = text.strip().strip('"').strip("'")
    # Cap length so URL encoding stays under route limits.
    if len(text) > 1400:
        text = text[:1400].rsplit(" ", 1)[0]
    return text or None


def _build_enhance_user_prompt(title: str, preset_name: str, subject_hint: str,
                               genre: str = "") -> str:
    """Shared user-side enhance prompt assembly so the Ollama and Pollinations
    text paths stay in lockstep."""
    preset = get_preset(preset_name)
    user_prompt = (
        f"Thumbnail concept: {title.strip()}\n"
        f"Niche: {preset['label']}\n"
        f"Subject placement direction: {preset['subject_placement']}\n"
        f"Atmosphere/composition baseline: {preset['composition_inject']}\n"
    )
    if genre.strip():
        user_prompt += (
            f"Script genre/tone (let this inform lighting, color, and mood): {genre.strip()}\n"
        )
    if subject_hint.strip():
        user_prompt += f"User-supplied subject hint (must respect): {subject_hint.strip()}\n"
    user_prompt += "\nWrite the enhanced prompt now."
    return user_prompt


# text.pollinations.ai is the free GET-based text endpoint that mirrors
# OpenAI's chat — same models (gpt-4o-mini, etc.), no API key required.
# We use it as the hosted-mode fallback when Ollama is unreachable so
# user-supplied custom prompts still get the cinematography-rich expansion.
_POLLINATIONS_TEXT_BASE = "https://text.pollinations.ai/"


def enhance_prompt_with_pollinations_text(title: str, *, preset_name: str = "faceless_story",
                                          subject_hint: str = "",
                                          genre: str = "",
                                          model: str = "openai",
                                          timeout: float = 25.0) -> str | None:
    """Free hosted-mode prompt enhancer using text.pollinations.ai. Same
    contract as enhance_prompt_with_ollama: returns enhanced text or None.
    Used as a fallback when Ollama isn't reachable (i.e. on phantomline.xyz
    where there is no local LLM)."""
    from urllib.parse import quote
    user_prompt = _build_enhance_user_prompt(title, preset_name, subject_hint, genre=genre)
    # POST is more reliable than GET for prompts > a few hundred chars.
    try:
        res = requests.post(
            _POLLINATIONS_TEXT_BASE,
            json={
                "messages": [
                    {"role": "system", "content": _ENHANCE_SYSTEM_PROMPT},
                    {"role": "user", "content": user_prompt},
                ],
                "model": model,
                "private": "true",
            },
            timeout=timeout,
        )
        if not res.ok:
            return None
        text = (res.text or "").strip()
    except Exception:
        return None
    if not text:
        return None
    text = re.sub(r"^(here'?s?|sure|okay)[,:\s]+.*?$", "", text, flags=re.IGNORECASE | re.MULTILINE).strip()
    text = text.strip().strip('"').strip("'")
    if len(text) > 1400:
        text = text[:1400].rsplit(" ", 1)[0]
    return text or None


def enhance_prompt_best_available(title: str, *, preset_name: str = "faceless_story",
                                  subject_hint: str = "",
                                  genre: str = "") -> str | None:
    """Best-available prompt enhancement. Tries local Ollama first
    (free + private + no rate limit), then falls back to text.pollinations.ai
    (free public endpoint). Returns None only if both fail — caller then
    uses the deterministic forge_thumbnail_prompt baseline."""
    enhanced = enhance_prompt_with_ollama(
        title, preset_name=preset_name, subject_hint=subject_hint, genre=genre
    )
    if enhanced:
        return enhanced
    return enhance_prompt_with_pollinations_text(
        title, preset_name=preset_name, subject_hint=subject_hint, genre=genre
    )


# ---------------------------------------------------------------------------
# Thumbnail HOOK generator (2026-research-backed)
#
# The naive approach of slugging the literal title produces dead overlay
# text like "THEY'RE ERASING MY MEMORIES ONE" — that's the title with
# trailing words lopped off, not a YouTube hook. Per the 1of10 study of
# 300K high-performing 2025 videos, thumbnails with text averaged ~19%
# fewer views EXCEPT when the text was under 10 characters or used an
# episode/case-tag pattern. The strongest faceless-creator examples
# (MrBallen, Lazy Masquerade, Nick Crowley) ship hooks like
# "FOUND FOOTAGE", "WHAT MOM HID", "PAGE 47", "CASE #12", "DON'T LOOK".
#
# This function takes the title + optional script context and produces
# a single 1-3 word punchy hook grounded in the actual story moment.
# Same Ollama → Pollinations text fallback as enhance_prompt_best_available
# so it works on hosted (no local LLM) AND local desktop.
# ---------------------------------------------------------------------------
_HOOK_SYSTEM_PROMPT = (
    "You are a YouTube thumbnail hook writer for faceless storytelling channels. "
    "You write the SHORT TEXT OVERLAY that goes ON TOP OF the thumbnail image. "
    "Your output goes ON the image, not in the title or description. "
    "\n\nRULES (from 2026 thumbnail research, strictly enforce):\n"
    "1. Output 1-3 words MAX. Never more than 3 words. Never a sentence.\n"
    "2. Never use the words 'the', 'a', 'an', 'and', 'or', 'but', 'is', 'was'.\n"
    "3. Reference a SPECIFIC moment, object, person, or twist from the story. "
    "Generic words like 'mystery', 'shocking', 'unbelievable' are forbidden.\n"
    "4. Patterns that work (pick one):\n"
    "   - Numeric hook: 'PAGE 47', 'ROOM 4B', 'DAY 12', 'CASE #047'\n"
    "   - Object reveal: 'THE DIARY', 'HER NOTE', 'THE TAPE'\n"
    "   - Relationship reveal: 'MOM LIED', 'DAD KNEW', 'SHE LEFT'\n"
    "   - Imperative: 'DON'T OPEN', 'DON'T LOOK', 'STAY OUT'\n"
    "   - Episode tag: 'FOUND FOOTAGE', 'TRUE STORY', 'EVIDENCE 12'\n"
    "   - Time hook: '20 YEARS LATER', 'THAT NIGHT', 'AT 3AM'\n"
    "5. Output in ALL CAPS, no quotes, no punctuation except apostrophe and #.\n"
    "6. NEVER repeat the literal title or first words of the title.\n"
    "7. NEVER include the channel name or generic descriptors.\n"
    "\nOutput the hook ONLY. No preamble, no explanation, no quotes. "
    "Plain text, ALL CAPS, 1-3 words."
)


def _build_hook_user_prompt(title: str, preset_name: str,
                            script_text: str = "", scenes_text: str = "") -> str:
    preset = get_preset(preset_name)
    parts = [f"Niche: {preset['label']}", f"Full title: {title.strip()}"]
    if script_text:
        # First 1500 chars of script: enough for the LLM to identify the
        # specific characters, objects, and twists worth surfacing in the
        # hook. Cap is generous because hook quality depends on context.
        parts.append(f"Story script (excerpt):\n{script_text[:1500]}")
    if scenes_text:
        parts.append(f"Scene beats:\n{scenes_text[:800]}")
    parts.append("\nWrite the 1-3 word ALL CAPS hook now. No preamble.")
    return "\n\n".join(parts)


def _normalize_hook(raw: str) -> str:
    """Strip LLM artifacts and enforce the 3-word ceiling.

    Robust against the common preamble patterns ("Sure, here's the hook:",
    "The hook is:", "Hook: ..."), against trailing reasoning in parens,
    and against the model returning the literal title slug. Final output
    is uppercase, max 3 words, no banned filler words."""
    if not raw:
        return ""
    text = raw.strip()
    # Pull out the first line — models sometimes add a "(why this works)"
    # trailer line.
    text = text.split("\n")[0].strip()
    # Drop everything after the first "(" — that's always commentary.
    if "(" in text:
        text = text.split("(", 1)[0].strip()
    # Strip multi-word preambles aggressively. Run repeatedly because a
    # model might stack ("Sure, the hook is: ...").
    preamble_patterns = [
        r"^sure[,!\s]+",
        r"^okay[,!\s]+",
        r"^here'?s?\b[^:]*?[:\-]\s*",       # "here's the hook:" / "here is:"
        r"^the\s+hook\s*[:\-]?\s*(is\s*[:\-]?\s*)?",
        r"^the\s+answer\s*[:\-]?\s*",
        r"^the\s+thumbnail(\s+text)?\s*[:\-]?\s*",
        r"^hook\s*[:=\-]\s*",
        r"^thumbnail(\s+text)?\s*[:=\-]\s*",
        r"^output\s*[:=\-]\s*",
        r"^answer\s*[:=\-]\s*",
        r"^text\s*[:=\-]\s*",
    ]
    for _ in range(4):  # up to 4 preamble layers
        progressed = False
        for pat in preamble_patterns:
            new = re.sub(pat, "", text, flags=re.IGNORECASE).strip()
            if new != text:
                text = new
                progressed = True
        if not progressed:
            break
    # Drop trailing reasoning after a dash ("FOUND FOOTAGE - this works…").
    if " - " in text or " – " in text or " — " in text:
        text = re.split(r"\s+[-–—]\s+", text, 1)[0].strip()
    # Strip wrapping quotes and trailing punctuation.
    text = text.strip().strip('"').strip("'").strip()
    text = re.sub(r"[.!?,;:\-–—]+$", "", text).strip()
    text = text.strip().strip('"').strip("'").strip()
    if not text:
        return ""
    # Filter banned filler words BEFORE the 3-word cap so "THE DIARY ROOM"
    # becomes "DIARY ROOM" rather than getting cut to "THE DIARY".
    banned = {"the", "a", "an", "and", "or", "but", "is", "was", "are",
              "were", "be", "been", "of", "to", "for", "in", "on", "at"}
    words = [w for w in text.split() if w.lower() not in banned]
    # Hard cap: 3 words.
    words = words[:3]
    return " ".join(words).upper()


def generate_thumbnail_hook(title: str, *, preset_name: str = "faceless_story",
                            script_text: str = "", scenes_text: str = "",
                            ollama_model: str = "llama3.1",
                            timeout: float = 25.0) -> str | None:
    """Generate a 1-3 word ALL CAPS hook for the thumbnail overlay.
    Tries local Ollama first, falls back to text.pollinations.ai. Returns
    None on total failure — caller falls back to the title slug."""
    user_prompt = _build_hook_user_prompt(title, preset_name, script_text, scenes_text)

    # Try Ollama first (free + private + no rate limit).
    if _ollama_reachable():
        try:
            import story_generator as _sg
            raw = _sg.generate(
                ollama_model,
                user_prompt,
                system=_HOOK_SYSTEM_PROMPT,
                label="thumbnail_hook",
                show_progress=False,
                temperature=0.55,
                num_predict=20,  # 1-3 words = ~10 tokens, plenty
            )
            normalized = _normalize_hook(raw or "")
            if normalized:
                return normalized
        except Exception:
            pass

    # Hosted fallback: text.pollinations.ai (free public endpoint).
    try:
        res = requests.post(
            _POLLINATIONS_TEXT_BASE,
            json={
                "messages": [
                    {"role": "system", "content": _HOOK_SYSTEM_PROMPT},
                    {"role": "user", "content": user_prompt},
                ],
                "model": "openai",
                "private": "true",
            },
            timeout=timeout,
        )
        if not res.ok:
            return None
        return _normalize_hook(res.text or "") or None
    except Exception:
        return None


def generate_pollinations_background(title: str, *, aspect: str = "16:9",
                                     preset_name: str = "faceless_story",
                                     subject_hint: str = "",
                                     genre: str = "",
                                     seed: int | None = None,
                                     enhance_prompt: bool = True,
                                     timeout: float = 90.0) -> bytes:
    """Hit Pollinations.ai's free FLUX endpoint. Returns PNG bytes resized
    to the canonical thumbnail size. Raises RuntimeError on transport/HTTP
    failure so the caller can fall back to fal.ai or PIL.

    Quality stack:
      1. If `enhance_prompt` is True and Ollama is reachable, expand the
         raw thumbnail concept into a cinematography-rich description
         (matches what ChatGPT does silently before DALL-E).
      2. Try models in priority order (flux-realism → flux → turbo).
         The first one to return a valid image wins.
      3. Caller applies post-processing for the final polish step.

    The prompt is URL-encoded and kept under ~2KB to avoid Pollinations'
    route truncation. Seeding lets us reproduce a specific generation in
    the batch endpoint."""
    from urllib.parse import quote

    width, height = THUMBNAIL_SIZES.get(aspect, THUMBNAIL_SIZES["16:9"])

    enhanced = None
    if enhance_prompt:
        # Best-available chain: Ollama (local) → text.pollinations.ai (free
        # public) → None. Hosted users without a local LLM still get prompt
        # expansion via the free public endpoint.
        enhanced = enhance_prompt_best_available(
            title, preset_name=preset_name, subject_hint=subject_hint, genre=genre
        )

    if enhanced:
        full_prompt = enhanced + ", no text, no captions, no logos, no watermarks"
    else:
        positive, _ = forge_thumbnail_prompt(
            title, preset_name=preset_name, subject_hint=subject_hint
        )
        full_prompt = positive[:1500] + ", no text, no captions, no logos, no watermarks"

    encoded = quote(full_prompt, safe="")

    last_error = None
    # Generate at 2048 max width (Pollinations free-tier ceiling for FLUX
    # without quota errors). The compose path resizes to the canonical
    # THUMBNAIL_SIZES anyway, so going higher gives sharper detail when
    # we upscale to 4K at output time.
    gen_w = min(width * 2, 2048) if width <= 1080 else min(width, 2048)
    gen_h = min(height * 2, 2048) if height <= 1080 else min(height, 2048)
    for model_name in _POLLINATIONS_MODEL_PRIORITY:
        params = {
            "width": str(gen_w),
            "height": str(gen_h),
            "model": model_name,
            "nologo": "true",
            "enhance": "false",
            "safe": "true",
        }
        if seed is not None:
            params["seed"] = str(int(seed))

        url = _POLLINATIONS_BASE + encoded + "?" + "&".join(f"{k}={v}" for k, v in params.items())

        try:
            res = requests.get(url, timeout=timeout, stream=True)
        except requests.RequestException as exc:
            last_error = f"{model_name}: transport — {exc}"
            continue

        if res.status_code != 200:
            last_error = f"{model_name}: HTTP {res.status_code}"
            continue

        # Cap download size to prevent abuse — 25 MB.
        chunks = []
        total = 0
        try:
            for chunk in res.iter_content(chunk_size=64 * 1024):
                chunks.append(chunk)
                total += len(chunk)
                if total > 25 * 1024 * 1024:
                    raise RuntimeError("Response exceeded 25 MB cap.")
        except Exception as exc:
            last_error = f"{model_name}: download — {exc}"
            continue
        raw = b"".join(chunks)

        try:
            with Image.open(BytesIO(raw)) as img:
                img = img.convert("RGB").resize((width, height), Image.LANCZOS)
                out = BytesIO()
                img.save(out, "PNG", optimize=True)
                return out.getvalue()
        except Exception as exc:
            last_error = f"{model_name}: decode — {exc}"
            continue

    raise RuntimeError(f"Pollinations: all models failed — {last_error}")


# ---------------------------------------------------------------------------
# fal.ai (FLUX schnell) — hosted SDXL/FLUX path
#
# fal.ai charges ~$0.005 per FLUX-schnell call as of 2026-04. The API
# returns 1024-bin images quickly (1-3s) and the quality beats vanilla
# SDXL by a wide margin, so this is the right hosted-deploy provider.
# Wire the FAL_KEY env var in Render dashboard; absence falls through
# to the PIL poster fallback.
# ---------------------------------------------------------------------------

_FALAI_ENDPOINT = "https://fal.run/fal-ai/flux/schnell"


def falai_available() -> bool:
    return bool((os.getenv("FAL_KEY") or os.getenv("FAL_API_KEY") or "").strip())


def generate_falai_background(title: str, *, aspect: str = "16:9",
                              preset_name: str = "faceless_story",
                              subject_hint: str = "",
                              seed: int | None = None) -> bytes:
    """Call fal.ai's FLUX schnell endpoint. Returns PNG bytes resized to
    the thumbnail's canonical width/height. Raises RuntimeError on any
    non-200 response so the caller can chain to a fallback."""
    key = (os.getenv("FAL_KEY") or os.getenv("FAL_API_KEY") or "").strip()
    if not key:
        raise RuntimeError("FAL_KEY not set; cannot use fal.ai path.")

    width, height = THUMBNAIL_SIZES.get(aspect, THUMBNAIL_SIZES["16:9"])
    positive, _negative = forge_thumbnail_prompt(
        title, preset_name=preset_name, subject_hint=subject_hint
    )

    # FLUX accepts named sizes or explicit dimensions. We bin to the
    # closest supported preset; fal upscales to our final size after.
    fal_size = {
        "16:9": "landscape_16_9",
        "9:16": "portrait_16_9",
        "4:5": "portrait_4_3",
        "1:1": "square_hd",
    }.get(aspect, "landscape_16_9")

    payload = {
        "prompt": positive,
        "image_size": fal_size,
        "num_inference_steps": 4,  # schnell is tuned for 1-4 steps
        "num_images": 1,
        "enable_safety_checker": True,
    }
    if seed is not None:
        payload["seed"] = int(seed)

    res = requests.post(
        _FALAI_ENDPOINT,
        json=payload,
        headers={"Authorization": f"Key {key}"},
        timeout=60,
    )
    if res.status_code >= 400:
        raise RuntimeError(f"fal.ai FLUX failed: HTTP {res.status_code} — {res.text[:200]}")

    try:
        data = res.json()
    except ValueError as exc:
        raise RuntimeError(f"fal.ai returned non-JSON: {res.text[:200]}") from exc

    images = data.get("images") or []
    if not images:
        raise RuntimeError(f"fal.ai returned no images: {data!r}")

    img_url = images[0].get("url")
    if not img_url:
        raise RuntimeError(f"fal.ai response missing url: {images[0]!r}")

    img_res = requests.get(img_url, timeout=30)
    if img_res.status_code != 200:
        raise RuntimeError(f"fal.ai image download failed: HTTP {img_res.status_code}")

    with Image.open(BytesIO(img_res.content)) as img:
        img = img.convert("RGB").resize((width, height), Image.LANCZOS)
        out = BytesIO()
        img.save(out, "PNG", optimize=True)
        return out.getvalue()


# ---------------------------------------------------------------------------
# PIL-only fallback poster compositions
#
# When neither Forge nor fal.ai is available, we still ship a thumbnail
# that doesn't look broken. Rather than punt to "diagonal lines on dark"
# we render real poster-grade compositions per preset:
#
#   rim_light       — vignetted dark with a single cyan rim glow on the
#                     right edge, evoking moonlight on a subject we can't
#                     actually generate
#   spotlight       — circular light cone descending from upper center,
#                     suggesting a single cosmic-horror anomaly without
#                     having to render it
#   dossier         — yellowed-paper texture with red stamp accent and
#                     coffee-ring stains, evidence-room aesthetic
#   diagonal_block  — dark slate split by a hot-orange angled wedge,
#                     the modern Fireship/explainer look
#   poster          — high-contrast magazine layout with a colored title
#                     band and accent bar, listicle territory
# ---------------------------------------------------------------------------


def _add_grain(img: Image.Image, amount: float = 0.03) -> Image.Image:
    """Adds film-grain noise. amount is 0-1; 0.03 is subtle, 0.08 heavy."""
    if amount <= 0:
        return img
    width, height = img.size
    # Build a low-res noise plate and upscale — smoother than per-pixel rand.
    noise_w, noise_h = max(1, width // 4), max(1, height // 4)
    noise = Image.new("L", (noise_w, noise_h))
    nd = noise.load()
    for y in range(noise_h):
        for x in range(noise_w):
            nd[x, y] = random.randint(0, 255)
    noise = noise.resize((width, height), Image.BILINEAR).filter(ImageFilter.GaussianBlur(0.6))
    grain = Image.new("RGB", (width, height), (128, 128, 128))
    grain = Image.blend(img, grain, amount)
    # Use the noise plate as a per-pixel mix mask so we don't flatten the image.
    return Image.composite(grain, img, noise.point(lambda v: int(v * amount * 255 / 128)))


def _radial_gradient(size: tuple[int, int], inner: tuple[int, int, int],
                     outer: tuple[int, int, int], center: tuple[float, float] = (0.5, 0.5),
                     radius_factor: float = 0.85) -> Image.Image:
    """Cheap radial gradient via per-row blending. Used for spotlights and
    vignettes. center is a (x_frac, y_frac) tuple in [0, 1]."""
    width, height = size
    cx, cy = int(width * center[0]), int(height * center[1])
    max_r = math.hypot(width, height) * radius_factor
    img = Image.new("RGB", size, outer)
    px = img.load()
    for y in range(height):
        for x in range(width):
            d = math.hypot(x - cx, y - cy) / max_r
            t = max(0.0, min(1.0, d))
            px[x, y] = (
                int(inner[0] + (outer[0] - inner[0]) * t),
                int(inner[1] + (outer[1] - inner[1]) * t),
                int(inner[2] + (outer[2] - inner[2]) * t),
            )
    return img


def _linear_gradient(size: tuple[int, int], top: tuple[int, int, int],
                     bottom: tuple[int, int, int], horizontal: bool = False) -> Image.Image:
    """Vertical (or horizontal) linear gradient."""
    width, height = size
    img = Image.new("RGB", size, top)
    draw = ImageDraw.Draw(img)
    span = width if horizontal else height
    for i in range(span):
        t = i / max(1, span - 1)
        c = (
            int(top[0] + (bottom[0] - top[0]) * t),
            int(top[1] + (bottom[1] - top[1]) * t),
            int(top[2] + (bottom[2] - top[2]) * t),
        )
        if horizontal:
            draw.line([(i, 0), (i, height)], fill=c)
        else:
            draw.line([(0, i), (width, i)], fill=c)
    return img


def _pattern_rim_light(width: int, height: int, preset: dict) -> Image.Image:
    """Vignetted dark canvas with a subtle cyan rim glow on the right
    edge. Suggests a backlit subject we couldn't actually generate."""
    bg = preset["bg"]
    accent = preset["accent"]
    rim = tuple(int(c * 0.45) for c in accent)
    img = _radial_gradient(
        (width, height),
        inner=rim,
        outer=bg,
        center=(0.92, 0.45),
        radius_factor=0.55,
    )
    # Darken everything else with a vignette
    overlay = _radial_gradient(
        (width, height), inner=(0, 0, 0), outer=bg, center=(0.5, 0.5), radius_factor=1.1
    )
    img = Image.blend(img, overlay, 0.45)
    return _add_grain(img, 0.04)


def _pattern_spotlight(width: int, height: int, preset: dict) -> Image.Image:
    """Cone of cold light descending from upper center over near-black —
    the cosmic-horror 'something is illuminated up there' suggestion."""
    bg = preset["bg"]
    accent = preset["accent"]
    cone_color = tuple(int(c * 0.55) for c in accent)
    img = _radial_gradient(
        (width, height),
        inner=cone_color,
        outer=bg,
        center=(0.5, 0.18),
        radius_factor=0.65,
    )
    return _add_grain(img, 0.05)


def _pattern_dossier(width: int, height: int, preset: dict) -> Image.Image:
    """Aged-paper background with a red stamp wedge and faint coffee
    rings. Evidence-room aesthetic for documentary niche."""
    paper = preset.get("secondary") or (199, 185, 160)
    accent = preset["accent"]
    bg = preset["bg"]
    img = _linear_gradient(
        (width, height),
        top=tuple(min(255, c + 18) for c in paper),
        bottom=tuple(max(0, c - 12) for c in paper),
    )
    draw = ImageDraw.Draw(img)
    # Red stamp wedge top-right
    stamp_pts = [
        (int(width * 0.62), int(height * 0.05)),
        (int(width * 0.98), int(height * 0.05)),
        (int(width * 0.98), int(height * 0.34)),
    ]
    draw.polygon(stamp_pts, fill=accent)
    # Coffee-ring stains
    for cx_f, cy_f, r_f in [(0.18, 0.78, 0.10), (0.72, 0.62, 0.07)]:
        cx, cy = int(width * cx_f), int(height * cy_f)
        r = int(min(width, height) * r_f)
        ring = Image.new("RGBA", (r * 2, r * 2), (0, 0, 0, 0))
        rd = ImageDraw.Draw(ring)
        rd.ellipse((0, 0, r * 2, r * 2), outline=(70, 40, 20, 80), width=4)
        rd.ellipse((6, 6, r * 2 - 6, r * 2 - 6), outline=(70, 40, 20, 50), width=2)
        ring = ring.filter(ImageFilter.GaussianBlur(2))
        img.paste(ring, (cx - r, cy - r), ring)
    # Subtle vignette
    vignette = _radial_gradient(
        (width, height), inner=(0, 0, 0), outer=bg, center=(0.5, 0.5), radius_factor=1.3
    )
    img = Image.blend(img, vignette, 0.25)
    return _add_grain(img, 0.06)


def _pattern_diagonal_block(width: int, height: int, preset: dict) -> Image.Image:
    """Dark slate split by a hot-accent angled wedge filling the right
    third. Modern explainer/Fireship look."""
    bg = preset["bg"]
    accent = preset["accent"]
    secondary = preset.get("secondary") or accent
    img = _linear_gradient(
        (width, height),
        top=bg,
        bottom=tuple(max(0, c - 8) for c in bg),
    )
    draw = ImageDraw.Draw(img)
    # Diagonal accent wedge — right two-fifths, angled
    wedge_pts = [
        (int(width * 0.55), 0),
        (width, 0),
        (width, height),
        (int(width * 0.42), height),
    ]
    overlay = Image.new("RGBA", (width, height), (0, 0, 0, 0))
    od = ImageDraw.Draw(overlay)
    od.polygon(wedge_pts, fill=accent + (160,))
    img = Image.alpha_composite(img.convert("RGBA"), overlay).convert("RGB")
    # Secondary accent bar near the wedge edge
    draw = ImageDraw.Draw(img)
    bar_x = int(width * 0.40)
    draw.rectangle((bar_x, 0, bar_x + 8, height), fill=secondary)
    return img


def _pattern_poster(width: int, height: int, preset: dict) -> Image.Image:
    """High-contrast magazine-poster layout for listicles. Solid black
    base, red title band on the left, gold accent stripe."""
    bg = preset["bg"]
    accent = preset["accent"]
    secondary = preset.get("secondary") or accent
    img = Image.new("RGB", (width, height), bg)
    draw = ImageDraw.Draw(img)
    # Big red angled band across center-left
    band_pts = [
        (0, int(height * 0.10)),
        (int(width * 0.62), int(height * 0.05)),
        (int(width * 0.58), int(height * 0.72)),
        (0, int(height * 0.78)),
    ]
    draw.polygon(band_pts, fill=accent)
    # Gold accent bar bottom
    draw.rectangle((0, int(height * 0.84), width, int(height * 0.88)), fill=secondary)
    # Subtle texture
    return _add_grain(img, 0.05)


_FALLBACK_PATTERNS = {
    "rim_light": _pattern_rim_light,
    "spotlight": _pattern_spotlight,
    "dossier": _pattern_dossier,
    "diagonal_block": _pattern_diagonal_block,
    "poster": _pattern_poster,
}


def generate_pil_background(title: str, *, aspect: str = "16:9",
                            preset_name: str = "faceless_story") -> bytes:
    """Render a poster-grade fallback for the preset.

    Per-preset `fallback_pattern` selects which composition. None of
    these claim to be photographs — they're explicitly stylized so a
    user knows they're getting the "no AI image generator available"
    fallback, but they look like a real designed thumbnail rather than
    a placeholder."""
    preset = get_preset(preset_name)
    width, height = THUMBNAIL_SIZES.get(aspect, THUMBNAIL_SIZES["16:9"])
    pattern_name = preset.get("fallback_pattern", "rim_light")
    pattern_fn = _FALLBACK_PATTERNS.get(pattern_name, _pattern_rim_light)
    img = pattern_fn(width, height, preset)
    out = BytesIO()
    img.save(out, "PNG", optimize=True)
    return out.getvalue()


# ---------------------------------------------------------------------------
# Title overlay composition
# ---------------------------------------------------------------------------


def _font(size: int, family: str = "bebas") -> ImageFont.FreeTypeFont:
    """Load a bundled font by family key. Falls through to OS fonts and
    finally PIL default if the bundled .ttf is missing (would only happen
    if static/fonts/ wasn't deployed)."""
    path = _FONT_FILES.get(family)
    if path and path.exists():
        try:
            return ImageFont.truetype(str(path), size)
        except OSError:
            pass
    # OS fallback — keep the old behavior so unbundled environments
    # don't crash the generator.
    os_candidates = {
        "anton": ["impact.ttf", "Impact.ttf", "arialbd.ttf"],
        "bebas": ["impact.ttf", "Impact.ttf", "arialbd.ttf"],
        "bangers": ["impact.ttf", "arialbd.ttf"],
        "montserrat_black": ["arialbd.ttf", "Arial Bold.ttf", "DejaVuSans-Bold.ttf"],
        "inter": ["arialbd.ttf", "Arial Bold.ttf", "DejaVuSans-Bold.ttf"],
    }.get(family, ["arialbd.ttf", "DejaVuSans-Bold.ttf"])
    for name in os_candidates:
        try:
            return ImageFont.truetype(name, size)
        except OSError:
            continue
    return ImageFont.load_default()


def _draw_distressed_text(draw: ImageDraw.ImageDraw,
                          xy: tuple[int, int],
                          text: str,
                          font: ImageFont.FreeTypeFont,
                          base_color: tuple[int, int, int] = (240, 235, 225),
                          stroke_width: int = 0,
                          shadow: bool = True,
                          target: Image.Image | None = None) -> None:
    """Render text with a distressed concrete/grunge fill — the EREBUS-
    poster look. Implementation: render the text into a transparent
    layer in solid color, generate a noise mask, multiply it against
    the text alpha so random pixels become semi-transparent. Result:
    text that looks weathered, not painted-on.

    Pass `target` to composite back into a parent RGB canvas; without
    it the function operates on the same image the draw is bound to."""
    if target is None:
        return  # need a target for compositing
    x, y = xy
    bbox = draw.textbbox((0, 0), text, font=font)
    text_w = bbox[2] - bbox[0]
    text_h = bbox[3] - bbox[1]
    pad = stroke_width * 2 + 24

    # Solid-color text on a transparent layer
    layer = Image.new("RGBA", (text_w + pad * 2, text_h + pad * 2), (0, 0, 0, 0))
    ldraw = ImageDraw.Draw(layer)
    if shadow:
        ldraw.text(
            (pad + 4, pad + 4), text, font=font,
            fill=(0, 0, 0, 200),
            stroke_width=stroke_width + 1 if stroke_width else 0,
            stroke_fill=(0, 0, 0, 200),
        )
    ldraw.text(
        (pad, pad), text, font=font, fill=base_color + (255,),
        stroke_width=stroke_width,
        stroke_fill=(0, 0, 0, 255) if stroke_width else (0, 0, 0, 0),
    )

    # Noise mask is intentionally absent — the previous aggressive noise
    # punched holes through thin letter segments ("PAGE" became "PAGF").
    # Reference thumbnails (EREBUS, IN THE CROWD) actually use bold
    # stencil typography with a subtle grain in the FILL, not destructive
    # noise on the alpha channel. We render the text clean here and let
    # the post-process polish step add a global film grain over the
    # whole composition for the weathered feel.
    target.paste(layer, (x - pad, y - pad), layer)


def _draw_text_with_stroke(draw: ImageDraw.ImageDraw,
                           xy: tuple[int, int],
                           text: str,
                           font: ImageFont.FreeTypeFont,
                           fill: tuple[int, int, int],
                           stroke_width: int = 6,
                           stroke_fill: tuple[int, int, int] = (0, 0, 0),
                           shadow: bool = True) -> None:
    """Render text with a thick black outline + drop shadow — the YouTube
    thumbnail typography signature. Used for subtitles/secondary text;
    headlines route through _draw_text_premium for the cinematic look."""
    x, y = xy
    if shadow:
        shadow_offset = max(2, stroke_width // 2)
        draw.text(
            (x + shadow_offset, y + shadow_offset),
            text,
            font=font,
            fill=(0, 0, 0),
            stroke_width=stroke_width + 1,
            stroke_fill=(0, 0, 0),
        )
    draw.text(
        (x, y),
        text,
        font=font,
        fill=fill,
        stroke_width=stroke_width,
        stroke_fill=stroke_fill,
    )


def _draw_text_premium(target: "Image.Image",
                       xy: tuple[int, int],
                       text: str,
                       font: ImageFont.FreeTypeFont,
                       fill: tuple[int, int, int],
                       stroke_width: int = 6,
                       texture_strength: float = 0.32) -> None:
    """Cinematic poster text: real Gaussian-blurred drop shadow +
    procedural granite/concrete texture overlay on the letter fill +
    edge inner-shadow. This is the EREBUS / IN THE CROWD look — text
    that reads as carved/stamped stone rather than flat painted-on.

    Key trick vs the old _draw_distressed_text: texture is applied as
    a MULTIPLY darken on RGB channels only, with alpha untouched. So
    letter shapes stay 100% intact (no "PAGE -> PAGF" letter-eating)
    but the fill gets a speckled granite finish that catches the eye.

    `texture_strength` 0.0 = flat fill (clean stencil); 1.0 = heavy
    weathered look. Default 0.32 is the EREBUS-grade speckle that
    survives at YouTube grid sizes without going noisy.
    """
    x, y = xy
    bbox = ImageDraw.Draw(target).textbbox((0, 0), text, font=font)
    text_w = bbox[2] - bbox[0]
    text_h = bbox[3] - bbox[1]
    pad = stroke_width * 3 + 32  # extra room for the soft shadow

    layer_w = text_w + pad * 2
    layer_h = text_h + pad * 2

    # 1. SHADOW LAYER — soft Gaussian-blurred shadow under the text.
    # Multiple blur passes give the "expensive" shadow look (close +
    # far layers, like a CSS multi-shadow).
    shadow_layer = Image.new("RGBA", (layer_w, layer_h), (0, 0, 0, 0))
    sd = ImageDraw.Draw(shadow_layer)
    # Far soft shadow for atmospheric depth.
    sd.text((pad + 6, pad + 8), text, font=font, fill=(0, 0, 0, 200),
            stroke_width=stroke_width, stroke_fill=(0, 0, 0, 200))
    shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(8))
    # Close hard-edged shadow for definition.
    sd2 = ImageDraw.Draw(shadow_layer)
    sd2.text((pad + 3, pad + 4), text, font=font, fill=(0, 0, 0, 180),
             stroke_width=stroke_width, stroke_fill=(0, 0, 0, 180))
    shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(2))

    # 2. STROKE LAYER — solid dark stroke outlines the letter forms so
    # they survive the texture pass and any background variation.
    stroke_layer = Image.new("RGBA", (layer_w, layer_h), (0, 0, 0, 0))
    ld = ImageDraw.Draw(stroke_layer)
    if stroke_width > 0:
        ld.text((pad, pad), text, font=font, fill=(0, 0, 0, 0),
                stroke_width=stroke_width, stroke_fill=(0, 0, 0, 255))

    # 3. FILL LAYER — solid color fill for the letter interiors.
    fill_layer = Image.new("RGBA", (layer_w, layer_h), (0, 0, 0, 0))
    fd = ImageDraw.Draw(fill_layer)
    fd.text((pad, pad), text, font=font, fill=fill + (255,),
            stroke_width=0)

    # 4. TEXTURE PASS — concrete/granite overlay applied ONLY where
    # the fill exists (alpha-masked). Procedural noise generated at low
    # res then upscaled gives larger speckle grains that look like
    # weathered stone, not pixel noise. Multiply blend darkens random
    # spots without erasing letter shape.
    if texture_strength > 0:
        noise_w, noise_h = max(8, layer_w // 6), max(8, layer_h // 6)
        noise = Image.new("L", (noise_w, noise_h))
        np_pixels = noise.load()
        for ny in range(noise_h):
            for nx in range(noise_w):
                # Bias toward lighter values so most of the fill stays
                # bright; only ~30% of pixels darken meaningfully.
                v = random.randint(0, 255)
                if v < 80:
                    # Dark speckle — strength controls how dark it gets.
                    np_pixels[nx, ny] = int(255 * (1 - texture_strength))
                elif v < 160:
                    # Mid speckle — half-strength darken.
                    np_pixels[nx, ny] = int(255 * (1 - texture_strength * 0.4))
                else:
                    np_pixels[nx, ny] = 255  # untouched
        noise = noise.resize((layer_w, layer_h), Image.BILINEAR)
        noise = noise.filter(ImageFilter.GaussianBlur(1.2))
        # Apply texture to RGB channels only (alpha untouched = letters
        # keep their shape). Mask each channel by the noise plate.
        r, g, b, a = fill_layer.split()
        r = ImageChops.multiply(r, noise)
        g = ImageChops.multiply(g, noise)
        b = ImageChops.multiply(b, noise)
        fill_layer = Image.merge("RGBA", (r, g, b, a))

    # 5. COMPOSITE in order: shadow under, stroke under fill, all onto
    # the target image at the original xy minus the padding offset.
    target_offset = (x - pad, y - pad)
    if target.mode != "RGBA":
        target_rgba = target.convert("RGBA")
    else:
        target_rgba = target
    # Shadow below
    target_rgba.alpha_composite(shadow_layer, target_offset)
    # Stroke below fill
    target_rgba.alpha_composite(stroke_layer, target_offset)
    # Fill on top
    target_rgba.alpha_composite(fill_layer, target_offset)
    # Bake back to original target
    if target.mode != "RGBA":
        target.paste(target_rgba.convert("RGB"))
    else:
        target.paste(target_rgba)


def _wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int,
               draw: ImageDraw.ImageDraw) -> list[str]:
    words = text.strip().split()
    if not words:
        return []
    lines: list[str] = []
    current = words[0]
    for word in words[1:]:
        candidate = current + " " + word
        bbox = draw.textbbox((0, 0), candidate, font=font)
        if bbox[2] - bbox[0] <= max_width:
            current = candidate
        else:
            lines.append(current)
            current = word
    lines.append(current)
    return lines


def _fit_text(text: str, max_width: int, max_height: int,
              draw: ImageDraw.ImageDraw,
              max_size: int, min_size: int,
              max_lines: int = 4,
              family: str = "bebas") -> tuple[ImageFont.FreeTypeFont, list[str], int]:
    for size in range(max_size, min_size - 1, -4):
        font = _font(size, family=family)
        lines = _wrap_text(text, font, max_width, draw)
        line_h = int(size * 1.05)
        total_h = line_h * len(lines)
        if total_h <= max_height and len(lines) <= max_lines:
            return font, lines, line_h
    font = _font(min_size, family=family)
    return font, _wrap_text(text, font, max_width, draw), int(min_size * 1.05)


def pick_contrast_accent(bg_image: "Image.Image",
                         palette: list[tuple[int, int, int]] | None = None,
                         sample_region: str = "left") -> tuple[int, int, int]:
    """Sample the AI background and return an accent color from a curated
    palette that has the highest contrast against the dominant background
    in the region where text will land. Avoids the always-cyan branding
    problem when one preset is shipped against many different image moods.

    `sample_region` can be 'left', 'right', 'lower', or 'full' — defaults
    to 'left' since that's where the headline text usually sits.

    Curated palette options have been chosen to look polished on YouTube:
    bold yellows, hot reds, clean cyans, warm oranges, and high-contrast
    white. We never return a near-black or near-white-on-white — those
    fail the contrast check naturally."""
    if palette is None:
        # Saturated colored options only — white is excluded because the
        # main title text is already rendered white, so a white "accent"
        # word would have zero visual hierarchy. The picker's job is to
        # find the colored highlight that pops AGAINST both the bg AND
        # the white title.
        palette = [
            (245, 199, 70),   # warm yellow
            (220, 50, 60),    # hot red
            (78, 220, 230),   # cyan
            (255, 140, 70),   # orange
            (240, 90, 150),   # hot pink
            (130, 240, 130),  # lime
            (180, 130, 255),  # purple
        ]
    # Resize for fast averaging. 32x18 keeps a 16:9 sample.
    w, h = bg_image.size
    small = bg_image.convert("RGB").resize((32, 18), Image.LANCZOS)
    if sample_region == "left":
        crop = small.crop((0, 0, 16, 18))
    elif sample_region == "right":
        crop = small.crop((16, 0, 32, 18))
    elif sample_region == "lower":
        crop = small.crop((0, 9, 32, 18))
    else:
        crop = small
    px = list(crop.getdata())
    if not px:
        return palette[0]
    avg = (
        sum(p[0] for p in px) // len(px),
        sum(p[1] for p in px) // len(px),
        sum(p[2] for p in px) // len(px),
    )

    def _luminance(c):
        # WCAG-relative luminance, perceptually weighted.
        return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]

    def _hue_distance(a, b):
        # Cheap HSV-ish distance: just euclidean in RGB normalized.
        return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))

    bg_lum = _luminance(avg)
    # Score each palette color by combined luminance contrast + hue distance.
    # Avoid colors whose luminance is within 60 of bg (would blend).
    best = palette[0]
    best_score = -1
    for c in palette:
        c_lum = _luminance(c)
        lum_delta = abs(c_lum - bg_lum)
        if lum_delta < 60:
            continue
        score = lum_delta * 0.6 + _hue_distance(avg, c) * 0.4
        if score > best_score:
            best_score = score
            best = c
    return best


def _shorten_for_overlay(title: str, max_words: int) -> str:
    """Truncate to the preset's max-word budget. No trailing ellipsis —
    "5…" reads as a wrong number, not a truncation. A clean cut at a
    word boundary lets the typography auto-fit handle the rest, and the
    title in the YouTube card / description carries the full payload
    anyway."""
    words = title.strip().split()
    if len(words) <= max_words:
        return title.strip()
    # Trim trailing connectors that produce awkward stops ("set up ollama in").
    truncated = words[:max_words]
    while truncated and truncated[-1].lower() in {
        "a", "an", "the", "of", "in", "on", "at", "to", "for", "and", "or", "but"
    }:
        truncated.pop()
    return " ".join(truncated) if truncated else " ".join(words[:max_words])


def _draw_pill_badge(target: Image.Image, anchor: tuple[int, int],
                     text: str, font: ImageFont.FreeTypeFont,
                     fill_color: tuple[int, int, int],
                     text_color: tuple[int, int, int] = (255, 255, 255),
                     padding: tuple[int, int] = (24, 12),
                     anchor_corner: str = "top-left",
                     icon: str = "") -> tuple[int, int]:
    """Draw a rounded-rect pill badge with text and optional icon glyph.
    Returns the bottom-right corner of the badge so callers can stack
    multiple badges. anchor_corner says which side the (x, y) refers to.

    Used for genre badges ("BASED ON TRUE EVENTS"), brand marks
    ("PHANTOMLINE"), and feature tags ("ORIGINAL STORY")."""
    layer = Image.new("RGBA", target.size, (0, 0, 0, 0))
    draw = ImageDraw.Draw(layer)

    # Measure
    label = (icon + " " + text).strip() if icon else text
    bbox = draw.textbbox((0, 0), label, font=font)
    text_w = bbox[2] - bbox[0]
    text_h = bbox[3] - bbox[1]
    pad_x, pad_y = padding
    pill_w = text_w + pad_x * 2
    pill_h = text_h + pad_y * 2

    # Resolve anchor
    ax, ay = anchor
    if anchor_corner == "top-right":
        x0 = ax - pill_w
        y0 = ay
    elif anchor_corner == "bottom-left":
        x0 = ax
        y0 = ay - pill_h
    elif anchor_corner == "bottom-right":
        x0 = ax - pill_w
        y0 = ay - pill_h
    else:  # top-left
        x0 = ax
        y0 = ay
    x1 = x0 + pill_w
    y1 = y0 + pill_h

    # Pill body
    radius = pill_h // 2
    draw.rounded_rectangle((x0, y0, x1, y1), radius=radius, fill=fill_color + (240,))
    # Text
    text_x = x0 + pad_x - bbox[0]
    text_y = y0 + pad_y - bbox[1]
    draw.text((text_x, text_y), label, font=font, fill=text_color + (255,))

    target.alpha_composite(layer) if target.mode == "RGBA" else (
        target.paste(Image.alpha_composite(target.convert("RGBA"), layer).convert("RGB"))
    )
    # When target is RGB we need to bake-back manually
    if target.mode != "RGBA":
        baked = Image.alpha_composite(target.convert("RGBA"), layer).convert("RGB")
        target.paste(baked)
    return x1, y1


def _fetch_subject_image(url: str, timeout: float = 15.0) -> bytes | None:
    """Download a user-supplied background image. Returns PNG bytes or
    None on failure. Used as a manual alternative to AI generation —
    user pastes a Pexels/Unsplash URL and we composite text over it."""
    if not url or not url.strip():
        return None
    if not url.lower().startswith(("https://", "http://")):
        return None
    try:
        res = requests.get(url, timeout=timeout, stream=True)
        if res.status_code != 200:
            return None
        ctype = (res.headers.get("Content-Type") or "").lower()
        if not ctype.startswith("image/"):
            return None
        # Cap download size to prevent abuse — 10 MB max.
        chunks = []
        total = 0
        for chunk in res.iter_content(chunk_size=64 * 1024):
            chunks.append(chunk)
            total += len(chunk)
            if total > 10 * 1024 * 1024:
                return None
        raw = b"".join(chunks)
        with Image.open(BytesIO(raw)) as img:
            img = img.convert("RGB")
            out = BytesIO()
            img.save(out, "PNG", optimize=True)
            return out.getvalue()
    except Exception:
        return None


def compose_thumbnail(title: str, background_png: bytes, *,
                      aspect: str = "16:9",
                      preset_name: str = "faceless_story",
                      subtitle: str = "",
                      tagline: str = "",
                      category_badge: str = "",
                      brand_badge: str = "",
                      feature_tags: list[str] | None = None,
                      text_overlay: bool | None = None,
                      bg_is_ai_image: bool = False,
                      headline_override: str = "") -> bytes:
    """Lay text on a background per the preset's rules.

    Composition picker:
    - When `bg_is_ai_image=True` AND preset suppresses text → minimal
      mode (AI image is the hero, just a small eyebrow + accent).
    - Otherwise → poster mode (text IS the hero, giant stroked
      typography that survives small renders).

    The "always go poster when there's no AI image" rule is a deliberate
    upgrade from the previous behavior: a textless thumbnail with no
    real subject behind it is just a dark rectangle. The 2026 research
    -19% text penalty applies when there's an actual photograph to
    compete with — without one, text has to do the work.
    """
    preset = get_preset(preset_name)
    width, height = THUMBNAIL_SIZES.get(aspect, THUMBNAIL_SIZES["16:9"])

    bg = Image.open(BytesIO(background_png)).convert("RGB").resize(
        (width, height), Image.LANCZOS
    )

    use_text = preset["text_default"] if text_overlay is None else bool(text_overlay)
    feature_tags = list(feature_tags or [])

    # Composition routing:
    #   text_overlay=False explicitly  → bare AI image, no overlay (escape hatch)
    #   AI image + minimal-text preset → small slug overlay (story/horror/doc)
    #   AI image + text-hero preset    → poster mode (listicle/explainer where text IS the hook)
    #   No AI image                    → poster mode (text has to do all the work)
    #
    # The "no overlay" branch only fires when the caller explicitly opts out.
    # Default behavior always composites at least a slug so the result is a
    # usable YouTube thumbnail, not a bare cinematic still.
    if text_overlay is False:
        out = BytesIO()
        bg.save(out, "PNG", optimize=True)
        return out.getvalue()

    if bg_is_ai_image and not preset["text_default"]:
        # Story/horror/doc: small clean slug on top of the cinematic image.
        # _compose_minimal_text auto-slugs the title via _shorten_for_overlay
        # and respects preset["text_max_words"], so it stays under the 1of10
        # research's "ultra-short text" budget.
        return _compose_minimal_text(
            bg, title, preset,
            subtitle=subtitle, tagline=tagline,
            category_badge=category_badge, brand_badge=brand_badge,
            feature_tags=feature_tags,
            headline_override=headline_override,
        )

    return _compose_poster(
        bg, title, preset,
        subtitle=subtitle, tagline=tagline,
        category_badge=category_badge, brand_badge=brand_badge,
        feature_tags=feature_tags,
    )


def _compose_poster(bg: Image.Image, title: str, preset: dict, *,
                    subtitle: str,
                    tagline: str = "",
                    category_badge: str = "",
                    brand_badge: str = "",
                    feature_tags: list[str] | None = None) -> bytes:
    """Poster-grade composition. Text is the hero: large stroked
    typography, accent eyebrow, optional numeral cluster pulled from the
    title. Used as the default when there's no AI image background, and
    for listicle preset always."""
    width, height = bg.size
    draw = ImageDraw.Draw(bg)

    title_font_family = preset.get("title_font", "bebas")
    sub_font_family = preset.get("subtitle_font", "inter")

    # Pull a leading numeral so listicles read as "20" + "MYSTERIOUS EVENTS"
    # not "20 MYSTERIOUS EVENTS" packed into one block. Numeric hooks are
    # the listicle signature; presets with no numeral fall through to a
    # single big headline.
    numeral_match = re.match(r"^\s*(\d{1,3})\b", title.strip())
    numeral = ""
    rest = title.strip()
    if numeral_match:
        numeral = numeral_match.group(1)
        rest = title[numeral_match.end():].strip()

    margin_x = int(width * 0.05)
    margin_y = int(height * 0.06)
    text_box_w = int(width * 0.88)

    cur_y = margin_y

    # Eyebrow subtitle in accent color.
    if subtitle.strip():
        sub_text = subtitle.strip().upper()
        sub_size = int(height * 0.045)
        sub_font = _font(sub_size, family=sub_font_family)
        _draw_text_with_stroke(
            draw, (margin_x, cur_y), sub_text,
            sub_font, fill=preset["accent"],
            stroke_width=4, shadow=False,
        )
        sub_bbox = draw.textbbox((0, 0), sub_text, font=sub_font)
        cur_y += (sub_bbox[3] - sub_bbox[1]) + int(height * 0.025)

    # Giant numeral if listicle-style.
    if numeral:
        num_font, _, _ = _fit_text(
            numeral, text_box_w, int(height * 0.45),
            draw, max_size=420, min_size=200, max_lines=1,
            family=preset.get("title_font", "bangers"),
        )
        _draw_text_with_stroke(
            draw, (margin_x, cur_y), numeral,
            num_font, fill=preset["accent"],
            stroke_width=10, shadow=True,
        )
        cur_y += int(num_font.size * 1.05)

    # Headline title. In poster mode (no AI image) the full title is the
    # hero — we don't truncate. The auto-fit shrinks the font until the
    # full string fits in 3 lines, going down to 60px if needed.
    headline_text = rest.upper()
    headline_bottom = cur_y
    if headline_text:
        # Reserve space for tagline + bottom badges so the title doesn't
        # collide with them.
        reserved_bottom = int(height * 0.20) if (tagline.strip() or brand_badge.strip() or feature_tags) else int(height * 0.10)
        remaining_h = max(int(height * 0.22), height - cur_y - reserved_bottom)
        head_font, head_lines, head_lh = _fit_text(
            headline_text, text_box_w, remaining_h,
            draw, max_size=180, min_size=60, max_lines=3,
            family=title_font_family,
        )
        for i, line in enumerate(head_lines):
            _draw_text_with_stroke(
                draw, (margin_x, cur_y + i * head_lh), line,
                head_font, fill=TEXT_PRIMARY,
                stroke_width=8, shadow=True,
            )
        headline_bottom = cur_y + len(head_lines) * head_lh

    # Tagline below the headline — accent-colored, smaller, multi-line OK.
    if tagline.strip():
        tag_size = int(height * 0.045)
        tag_font = _font(tag_size, family=sub_font_family)
        tag_lines = _wrap_text(tagline.strip(), tag_font, text_box_w, draw)[:2]
        tag_y = headline_bottom + int(height * 0.02)
        for line in tag_lines:
            _draw_text_with_stroke(
                draw, (margin_x, tag_y), line,
                tag_font, fill=preset["accent"],
                stroke_width=3, shadow=True,
            )
            tag_y += int(tag_size * 1.2)

    _apply_corner_badges(bg, preset,
                         category_badge=category_badge,
                         brand_badge=brand_badge,
                         feature_tags=feature_tags or [])

    out = BytesIO()
    bg.save(out, "PNG", optimize=True)
    return out.getvalue()


def _apply_corner_badges(bg: Image.Image, preset: dict, *,
                         category_badge: str = "",
                         brand_badge: str = "",
                         feature_tags: list[str]) -> None:
    """Render the 3 standard corner badge slots on top of `bg`:
      - category_badge: top-left, accent-colored pill ("BEDTIME STORY")
      - brand_badge: bottom-left, dark pill with accent text ("PHANTOMLINE")
      - feature_tags: bottom-right, list of small dark pills with accent
        text (each "ORIGINAL STORY", "SOOTHING NARRATION")

    Mutates `bg` in place via _draw_pill_badge — that helper handles the
    RGB/RGBA conversion internally."""
    width, height = bg.size
    badge_font = _font(int(height * 0.034), family="montserrat_black")
    margin = int(width * 0.025)

    if category_badge.strip():
        _draw_pill_badge(
            bg, anchor=(margin, margin),
            text=category_badge.strip().upper(), font=badge_font,
            fill_color=preset["accent"], text_color=(0, 0, 0),
            anchor_corner="top-left",
        )

    if brand_badge.strip():
        small_font = _font(int(height * 0.030), family="montserrat_black")
        _draw_pill_badge(
            bg, anchor=(margin, height - margin),
            text=brand_badge.strip().upper(), font=small_font,
            fill_color=(0, 0, 0), text_color=preset["accent"],
            anchor_corner="bottom-left",
        )

    if feature_tags:
        # Stack right-to-left so the "first" tag ends up rightmost.
        tag_font = _font(int(height * 0.026), family="montserrat_black")
        x_anchor = width - margin
        for tag in feature_tags[:3]:  # cap at 3 to avoid clutter
            x1, _ = _draw_pill_badge(
                bg, anchor=(x_anchor, height - margin),
                text=tag.strip().upper(), font=tag_font,
                fill_color=(0, 0, 0), text_color=preset.get("secondary") or preset["accent"],
                anchor_corner="bottom-right",
                padding=(18, 10),
            )
            # Move next anchor to the left of this badge with a gap.
            # _draw_pill_badge returns (x1, y1) which for bottom-right
            # is the right-most pixel; we need the left edge for stacking.
            # Recompute using the same width formula approximately:
            bbox = ImageDraw.Draw(bg).textbbox((0, 0), tag.upper(), font=tag_font)
            badge_w = (bbox[2] - bbox[0]) + 18 * 2
            x_anchor -= badge_w + int(width * 0.012)


def _compose_minimal_text(bg: Image.Image, title: str, preset: dict, *,
                          subtitle: str,
                          tagline: str = "",
                          category_badge: str = "",
                          brand_badge: str = "",
                          feature_tags: list[str] | None = None,
                          headline_override: str = "") -> bytes:
    """AI-image overlay composition matching the reference EREBUS / IN THE
    CROWD / BEDTIME STORY thumbnails:
      - HUGE distressed/stenciled title (~25% of frame height) on the
        text side, with the last word OR any number rendered in the
        preset's accent color so one word "pops"
      - Top-left genre badge pill (BASED ON TRUE EVENTS / BEDTIME STORY /
        TRUE CRIME / etc.) auto-supplied from preset["genre_badge"] when
        caller doesn't override
      - Optional subtitle line under the title in preset.secondary color
        (italic-feeling), with subtitle keywords highlighted in accent
        if the caller provides them via the {accent}word{/accent} marker
      - Bottom corner feature pills (ORIGINAL STORY / NARRATED) auto-
        supplied from preset["feature_tag_defaults"]

    `headline_override` (1-3 word LLM hook) wins over the title slug.
    """
    width, height = bg.size

    sub_font_family = preset.get("subtitle_font", "inter")
    title_font_family = preset.get("title_font", "bebas")
    pos = preset["text_position"]
    # Dynamic accent: sample the AI background and pick a palette color
    # with the strongest contrast against where the headline lands. This
    # avoids the "every thumbnail is cyan" branding-template look. The
    # preset's accent stays as a fallback for badges so brand recognition
    # still survives.
    sample_region = "left" if ("left" in pos or "center" in pos) else "lower"
    headline_accent = pick_contrast_accent(bg, sample_region=sample_region)
    secondary = preset.get("secondary") or preset["accent"]

    # Side-band vignette so text stays readable over any AI image. Lighter
    # than before (alpha 90) and bigger so the title has room to breathe.
    overlay = Image.new("RGBA", (width, height), (0, 0, 0, 0))
    od = ImageDraw.Draw(overlay)
    band_alpha = 110
    if "lower" in pos:
        od.rectangle((0, int(height * 0.55), width, height), fill=(0, 0, 0, band_alpha))
    elif "left" in pos or "center" in pos:
        # Left half-band is the EREBUS / IN THE CROWD pattern.
        od.rectangle((0, 0, int(width * 0.60), height), fill=(0, 0, 0, band_alpha))
    elif "top" in pos:
        od.rectangle((0, 0, width, int(height * 0.45)), fill=(0, 0, 0, band_alpha))
    bg = Image.alpha_composite(bg.convert("RGBA"), overlay).convert("RGB")
    draw = ImageDraw.Draw(bg)

    margin_x = int(width * 0.04)
    margin_y = int(height * 0.06)
    text_box_w = int(width * 0.55)

    # Resolve the headline. Hook wins; otherwise slug the title.
    if headline_override.strip():
        headline = headline_override.strip().upper()
    else:
        headline = _shorten_for_overlay(title, preset["text_max_words"]).upper()

    # Smart line split: 1 line if <=2 words (PAGE 47), 2 lines if 3 words
    # (split 1+2 OR 2+1 depending on which line is longer to balance).
    words = headline.split()
    if len(words) <= 2:
        lines = [headline]
        max_lines = 1
    elif len(words) == 3:
        # Balance for visual weight: longer-character word on its own line.
        if len(words[0]) >= len(words[2]):
            lines = [words[0], " ".join(words[1:])]
        else:
            lines = [" ".join(words[:2]), words[2]]
        max_lines = 2
    else:
        # 4+ words (rare with hook generator) — stack roughly half/half.
        mid = len(words) // 2
        lines = [" ".join(words[:mid]), " ".join(words[mid:])]
        max_lines = 2

    # Pick the "accent" word: a number wins; otherwise the LAST word.
    accent_word_idx = None
    for i, w in enumerate(words):
        if re.search(r"\d", w):
            accent_word_idx = i
            break
    if accent_word_idx is None and len(words) >= 2:
        accent_word_idx = len(words) - 1

    # Anchor Y for the title block — start near the top so the badge can
    # sit above and the subtitle can sit below.
    if "top" in pos:
        title_y = margin_y + int(height * 0.10)
    elif "lower" in pos:
        title_y = int(height * 0.42)
    else:
        title_y = int(height * 0.18)

    # Big distressed title. Each line is fitted independently so the
    # accent word can be split across colored draws, and so the longer
    # line sets the size.
    title_box_h = int(height * (0.42 if max_lines == 2 else 0.32))
    title_font, _fitted_lines, line_h = _fit_text(
        max(lines, key=lambda s: len(s)),
        text_box_w, title_box_h,
        draw, max_size=int(height * 0.30), min_size=int(height * 0.10),
        max_lines=1, family=title_font_family,
    )

    cur_y = title_y
    word_idx = 0
    # Premium poster typography: real Gaussian-blurred multi-layer drop
    # shadow + procedural granite/concrete texture overlay on the letter
    # fill. Texture is multiply-blended on RGB only (alpha untouched) so
    # letter shapes stay 100% intact — no "PAGE -> PAGF" letter-eating.
    # Reads as carved stone rather than flat painted text.
    for line in lines:
        line_words = line.split()
        x = margin_x
        for w in line_words:
            color = headline_accent if word_idx == accent_word_idx else TEXT_PRIMARY
            _draw_text_premium(
                bg, (x, cur_y), w,
                title_font, fill=color,
                stroke_width=8,
                texture_strength=0.32,
            )
            wb = draw.textbbox((0, 0), w, font=title_font)
            x += (wb[2] - wb[0]) + int(line_h * 0.15)
            word_idx += 1
        cur_y += int(line_h * 1.05)

    # Optional subtitle under the title in secondary color.
    sub_text = subtitle.strip() if subtitle.strip() else (
        # Auto-derive a short subtitle from the original title only if the
        # caller didn't supply one and the headline was overridden (so the
        # original title isn't already on-screen).
        _shorten_for_overlay(title, 6).upper() if headline_override.strip() else ""
    )
    if sub_text and headline_override.strip() and sub_text.upper() != headline.upper():
        sub_size = max(int(height * 0.034), 22)
        sub_font = _font(sub_size, family=sub_font_family)
        sub_lines = _wrap_text(sub_text, sub_font, text_box_w, draw)[:2]
        for line in sub_lines:
            _draw_text_with_stroke(
                draw, (margin_x, cur_y), line,
                sub_font, fill=secondary,
                stroke_width=2, shadow=True,
            )
            cur_y += int(sub_size * 1.2)

    # Auto-supply genre_badge + feature_tags from preset defaults when the
    # caller didn't pass them. This is what gives the thumbnail the BEDTIME
    # STORY / BASED ON TRUE EVENTS top-left pill from the references.
    effective_category = category_badge.strip() or preset.get("genre_badge", "")
    effective_features = feature_tags if feature_tags else preset.get("feature_tag_defaults", [])

    _apply_corner_badges(bg, preset,
                         category_badge=effective_category,
                         brand_badge=brand_badge,
                         feature_tags=effective_features or [])

    out = BytesIO()
    bg.save(out, "PNG", optimize=True)
    return out.getvalue()


# ---------------------------------------------------------------------------
# Top-level entry point
# ---------------------------------------------------------------------------


def _resolve_preset_name(title: str, style: str, genre: str, recipe: str) -> str:
    if style == "auto" or not style:
        return detect_preset(title, genre=genre, recipe=recipe)
    if style in THUMBNAIL_PRESETS:
        return style
    return "faceless_story"


def generate_thumbnail(title: str, *, aspect: str = "16:9",
                       style: str = "auto",
                       genre: str = "",
                       recipe: str = "",
                       subject_hint: str = "",
                       subtitle: str = "",
                       tagline: str = "",
                       category_badge: str = "",
                       brand_badge: str = "",
                       feature_tags: list[str] | None = None,
                       subject_image_url: str = "",
                       text_overlay: bool | None = None,
                       prefer_forge: bool = True,
                       prefer_pollinations: bool = True,
                       prefer_falai: bool = True,
                       seed: int | None = None,
                       headline_override: str = "") -> dict:
    """Generate a single thumbnail. Tries backends in priority order:

      1. `subject_image_url` if supplied (user paste-your-own-photo path)
      2. Forge SDXL (local, free, runs on the user's machine if installed)
      3. Pollinations.ai (FREE FLUX endpoint, no API key, the default)
      4. fal.ai FLUX schnell (paid, ~$0.005/call, more reliable)
      5. PIL poster fallback (always works, typography-only)

    The first backend to succeed wins. The fallback chain is silent on
    intermediate failures — `fallback_reason` is only populated if all
    AI backends fail."""
    preset_name = _resolve_preset_name(title, style, genre, recipe)
    width, height = THUMBNAIL_SIZES.get(aspect, THUMBNAIL_SIZES["16:9"])

    bg_bytes: bytes | None = None
    used_mode = "pil_fallback"
    fallback_reason = ""
    bg_is_ai_image = False

    # User-supplied URL wins. This is the manual escape hatch for users
    # who don't want to plug in Forge/fal.ai but still want a real photo
    # behind the text — they paste a Pexels/Unsplash URL.
    if subject_image_url and subject_image_url.strip():
        fetched = _fetch_subject_image(subject_image_url.strip())
        if fetched is not None:
            bg_bytes = fetched
            used_mode = "user_image"
            bg_is_ai_image = True  # treat as a real photo, suppresses poster mode
        else:
            fallback_reason = "Provided subject_image_url could not be fetched."

    if bg_bytes is None and prefer_forge and forge_available():
        try:
            bg_bytes = generate_forge_background(
                title, aspect=aspect, preset_name=preset_name,
                subject_hint=subject_hint,
            )
            used_mode = "forge"
            bg_is_ai_image = True
            fallback_reason = ""
        except Exception as exc:
            fallback_reason = (fallback_reason + " | " if fallback_reason else "") + f"Forge: {exc}"

    if bg_bytes is None and prefer_pollinations and pollinations_available():
        try:
            bg_bytes = generate_pollinations_background(
                title, aspect=aspect, preset_name=preset_name,
                subject_hint=subject_hint, genre=genre, seed=seed,
            )
            used_mode = "pollinations"
            bg_is_ai_image = True
            fallback_reason = ""
        except Exception as exc:
            fallback_reason = (fallback_reason + " | " if fallback_reason else "") + f"Pollinations: {exc}"

    if bg_bytes is None and prefer_falai and falai_available():
        try:
            bg_bytes = generate_falai_background(
                title, aspect=aspect, preset_name=preset_name,
                subject_hint=subject_hint, seed=seed,
            )
            used_mode = "falai"
            bg_is_ai_image = True
            fallback_reason = ""
        except Exception as exc:
            fallback_reason = (fallback_reason + " | " if fallback_reason else "") + f"fal.ai: {exc}"

    if bg_bytes is None:
        bg_bytes = generate_pil_background(title, aspect=aspect, preset_name=preset_name)
        if not fallback_reason:
            fallback_reason = "No AI image backend configured (Forge or FAL_KEY)."

    # Post-process AI-generated backgrounds for the "professional 4K
    # polish" finish — sharpen, contrast, saturation, vignette. We skip
    # this for the typography-only PIL fallback (already designed) and
    # for user-supplied images (assume the user already curated them).
    if bg_is_ai_image and used_mode in ("forge", "pollinations", "falai"):
        try:
            bg_bytes = post_process_background(bg_bytes, preset_name=preset_name)
        except Exception:
            # Don't fail the whole request if post-processing crashes —
            # original AI image is still better than nothing.
            pass

    composed = compose_thumbnail(
        title, bg_bytes, aspect=aspect,
        preset_name=preset_name,
        subtitle=subtitle,
        tagline=tagline,
        category_badge=category_badge,
        brand_badge=brand_badge,
        feature_tags=feature_tags,
        text_overlay=text_overlay,
        bg_is_ai_image=bg_is_ai_image,
        headline_override=headline_override,
    )

    # Final 4K upscale — push the composite to YouTube's recommended
    # 3840×2160 with LANCZOS + edge-preserving sharpen. The text was
    # composited at the source resolution (1920×1080) so it gets
    # naturally crisper rather than blurry. Skipped for the PIL fallback
    # since that's already a designed-not-photographed surface.
    if bg_is_ai_image and used_mode in ("forge", "pollinations", "falai", "user_image"):
        try:
            composed = upscale_to_4k(composed, target_long_edge=3840)
        except Exception:
            # Upscale is best-effort; original 1080p composite is still
            # a usable thumbnail if the upscale errors.
            pass
    # Report the actual final dimensions (post-upscale) so the UI shows
    # the user the real 4K size they're getting.
    final_w, final_h = width, height
    try:
        with Image.open(BytesIO(composed)) as _final:
            final_w, final_h = _final.size
    except Exception:
        pass
    return {
        "png_bytes": composed,
        "mode": used_mode,
        "preset": preset_name,
        "width": final_w,
        "height": final_h,
        "fallback_reason": fallback_reason if not bg_is_ai_image else "",
    }


def generate_thumbnail_batch(title: str, *, count: int = 4, **kwargs) -> list[dict]:
    """Generate `count` thumbnail variants with different seeds so the
    user has real picks to pull from. Each call lands as a separate API
    request to the AI backend; the PIL fallback gets visual variety from
    randomized grain patterns and seed-driven color shifts in the
    pattern functions.

    Returns a list of dicts with the same shape as `generate_thumbnail`
    plus a `seed` field per variant."""
    count = max(1, min(8, count))
    results = []
    for i in range(count):
        # Use a deterministic-ish seed range so users can re-roll a
        # specific variant by passing seed=N explicitly. Random.randint
        # avoids accidental seed collisions across rapid-fire calls.
        seed = kwargs.pop("seed", None) if i == 0 and "seed" in kwargs else random.randint(1, 2**31 - 1)
        result = generate_thumbnail(title, seed=seed, **kwargs)
        result["seed"] = seed
        results.append(result)
    return results


def save_thumbnail(png_bytes: bytes, out_path: Path) -> Path:
    out_path = Path(out_path)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_bytes(png_bytes)
    return out_path
