"""Compose poster (2:3) and landscape (16:9) images with optional text overlay."""

import copy
import os

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

from .crop_selector import apply_crop_position
from .fonts import get_system_font, load_template_font
from .poster_template import _BUILTIN_TEMPLATE

# Re-export dimension constants for backwards-compatible test imports
POSTER_WIDTH: int = _BUILTIN_TEMPLATE["poster"]["width"]
POSTER_HEIGHT: int = _BUILTIN_TEMPLATE["poster"]["height"]
LANDSCAPE_WIDTH: int = _BUILTIN_TEMPLATE["landscape"]["width"]
LANDSCAPE_HEIGHT: int = _BUILTIN_TEMPLATE["landscape"]["height"]


# Backwards-compatible aliases delegating to the centralized fonts module
def _get_bold_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
    """Return a bold system font (alias for :func:`fonts.get_system_font` with bold=True)."""
    return get_system_font(size, bold=True)


def _get_regular_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
    """Return a regular system font (alias for :func:`fonts.get_system_font` with bold=False)."""
    return get_system_font(size, bold=False)


def _load_font(
    template: dict,
    size: int,
    bold: bool = False,
) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
    """Load a template-configured font (alias for :func:`fonts.load_template_font`)."""
    return load_template_font(template, size, bold)


def _apply_vignette(image: Image.Image, vignette_strength: float = 0.18) -> Image.Image:
    """Apply a subtle radial vignette to *image*.

    Computed at a small size and upscaled for performance—no full-resolution
    pixel-by-pixel loop.
    """
    w, h = image.size
    small = 64
    half = small / 2.0
    pixels = [
        int((1.0 - vignette_strength * min(1.0, ((x - half) ** 2 + (y - half) ** 2) ** 0.5 / half)) * 255)
        for y in range(small)
        for x in range(small)
    ]
    mask_small = Image.new("L", (small, small))
    mask_small.putdata(pixels)
    mask = mask_small.resize((w, h), Image.LANCZOS)
    black = Image.new("RGB", (w, h), (0, 0, 0))
    return Image.composite(image, black, mask)


def _measure_text(
    text: str,
    font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
) -> tuple[int, int]:
    """Return (width, height) of *text* rendered with *font*."""
    dummy = Image.new("RGB", (1, 1))
    draw = ImageDraw.Draw(dummy)
    bbox = draw.textbbox((0, 0), text, font=font)
    return bbox[2] - bbox[0], bbox[3] - bbox[1]


def _draw_text_with_shadow(
    draw: ImageDraw.ImageDraw,
    xy: tuple[int, int],
    text: str,
    font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
    fill: tuple[int, int, int],
    shadow_offset: tuple[int, int] = (2, 2),
    shadow_alpha: int = 128,
) -> None:
    """Draw *text* at *xy* with a subtle 1-2px drop shadow."""
    sx, sy = xy[0] + shadow_offset[0], xy[1] + shadow_offset[1]
    draw.text((sx, sy), text, fill=(0, 0, 0, shadow_alpha), font=font)
    draw.text(xy, text, fill=fill, font=font)


def _auto_size_font(
    text: str,
    area_width: int,
    area_height: int,
    inner_padding: int = 20,
    min_font_size: int = 24,
    max_font_size: int = 80,
    template: dict | None = None,
    bold: bool = True,
) -> tuple[ImageFont.FreeTypeFont | ImageFont.ImageFont, int]:
    """Choose the largest font that fits *text* in the given area.

    Returns ``(font, font_size)``.
    """
    max_width = area_width - 2 * inner_padding
    tmpl = template if template is not None else {}

    dummy = Image.new("RGB", (1, 1))
    draw = ImageDraw.Draw(dummy)

    for font_size in range(max_font_size, min_font_size - 1, -4):
        font = _load_font(tmpl, font_size, bold=bold)
        lines = _wrap_text_to_width(text, font, max_width)

        line_heights = [
            draw.textbbox((0, 0), line, font=font)[3]
            - draw.textbbox((0, 0), line, font=font)[1]
            for line in lines
        ]
        # Must match the spacing used in _render_title_centered
        avg_h = (sum(line_heights) // len(line_heights)) if line_heights else 40
        line_spacing = max(8, avg_h // 4)
        total_height = sum(line_heights) + line_spacing * (len(lines) - 1)

        if total_height <= area_height:
            return font, font_size

    return _load_font(tmpl, min_font_size, bold=bold), min_font_size


def _wrap_text_to_width(
    text: str,
    font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
    max_width: int,
) -> list[str]:
    """Wrap *text* to fit within *max_width* pixels. Returns a list of lines."""
    dummy = Image.new("RGB", (1, 1))
    draw = ImageDraw.Draw(dummy)

    words = text.split()
    lines: list[str] = []
    current: list[str] = []

    for word in words:
        candidate = " ".join(current + [word])
        bbox = draw.textbbox((0, 0), candidate, font=font)
        if bbox[2] - bbox[0] <= max_width:
            current.append(word)
        else:
            if current:
                lines.append(" ".join(current))
            current = [word]

    if current:
        lines.append(" ".join(current))

    return lines if lines else [text]


def _render_title_centered(
    draw: ImageDraw.ImageDraw,
    text: str,
    area_x: int,
    area_y: int,
    area_width: int,
    area_height: int,
    font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
    inner_padding: int = 20,
    shadow_offset: tuple[int, int] = (2, 2),
    shadow_alpha: int = 128,
) -> None:
    """Render bold title *text* centered with drop shadow in the given area."""
    max_width = area_width - 2 * inner_padding
    lines = _wrap_text_to_width(text, font, max_width)

    dummy = Image.new("RGB", (1, 1))
    dummy_draw = ImageDraw.Draw(dummy)

    line_heights = [
        dummy_draw.textbbox((0, 0), line, font=font)[3]
        - dummy_draw.textbbox((0, 0), line, font=font)[1]
        for line in lines
    ]
    # Comfortable line spacing: ~25% of average line height (min 8 px)
    avg_h = (sum(line_heights) // len(line_heights)) if line_heights else 40
    line_spacing = max(8, avg_h // 4)
    total_height = sum(line_heights) + line_spacing * (len(lines) - 1)

    y = area_y + (area_height - total_height) // 2

    for i, line in enumerate(lines):
        bbox = dummy_draw.textbbox((0, 0), line, font=font)
        line_width = bbox[2] - bbox[0]
        x = area_x + (area_width - line_width) // 2
        _draw_text_with_shadow(draw, (x, y), line, font, (255, 255, 255),
                               shadow_offset=shadow_offset, shadow_alpha=shadow_alpha)
        y += line_heights[i] + line_spacing


def _render_poster_text_area(
    poster: Image.Image,
    inner_x1: int,
    inner_y1: int,
    inner_x2: int,
    inner_y2: int,
    overlay_title: str | None,
    overlay_category: str | None,
    overlay_category_logo_path: str | None,
    overlay_note: str | None,
    template: dict,
    badges: list[str] | None = None,
) -> None:
    """Render all text/logo elements inside the poster frame area."""
    inner_width = inner_x2 - inner_x1

    t_frame = template["frame"]
    t_title = template["title"]
    t_category = template["category"]
    t_note = template["note"]
    t_logo = template["logo"]
    t_shadow = template["shadow"]

    inner_padding: int = t_frame["inner_padding"]
    shadow_offset = (t_shadow["offset_x"], t_shadow["offset_y"])
    shadow_alpha: int = t_shadow["alpha"]

    draw = ImageDraw.Draw(poster)

    # ── Note / badge row reservation ─────────────────────────────────────────
    # Reserve space for whichever is taller: note text or badges — prevents
    # badges from overflowing the inner_padding boundary when badge_height > note_font_size.
    note_font_size: int = t_note["font_size"]
    t_badges_cfg = template.get("badges", {})
    badge_height_reserved: int = t_badges_cfg.get("height", 36) if badges else 0
    has_bottom_row = bool(overlay_note or badges)
    row_height = max(note_font_size if overlay_note else 0, badge_height_reserved)
    note_h = (row_height + 8) if has_bottom_row else 0

    # ── Category / logo area ─────────────────────────────────────────────────
    logo: Optional[Image.Image] = None
    logo_is_wide = False
    logo_w = logo_h = 0

    logo_wide_max_height: int = t_logo["wide_max_height"]
    logo_portrait_max_height: int = t_logo["portrait_max_height"]
    logo_wide_threshold: float = t_logo["wide_threshold"]

    if overlay_category_logo_path:
        logo = Image.open(overlay_category_logo_path).convert("RGBA")
        logo_is_wide = logo.width > logo.height * logo_wide_threshold
        if logo_is_wide:
            scale = logo_wide_max_height / logo.height
            logo_h = logo_wide_max_height
            logo_w = int(logo.width * scale)
        else:
            scale = min(1.0, logo_portrait_max_height / logo.height)
            logo_h = int(logo.height * scale)
            logo_w = int(logo.width * scale)
        logo = logo.resize((logo_w, logo_h), Image.LANCZOS)

    # Vertical space reserved above title (wide logo or category text)
    # Portrait/square logo sits beside the title — no extra vertical space
    cat_area_h = 0
    category_gap: int = t_category["gap"]
    title_max_font_size: int = t_title["max_font_size"]
    category_size_ratio: float = t_category["size_ratio"]

    if logo and logo_is_wide:
        cat_area_h = logo_h + category_gap
    elif overlay_category:
        est_cat_size = max(32, min(56, int(title_max_font_size * category_size_ratio)))
        _cat_w, cat_text_h = _measure_text(overlay_category, _load_font(template, est_cat_size, bold=False))
        cat_area_h = cat_text_h + category_gap

    # ── Title area ───────────────────────────────────────────────────────────
    title_x1 = inner_x1
    title_x2 = inner_x2
    title_y1 = inner_y1 + inner_padding + cat_area_h
    title_y2 = inner_y2 - inner_padding - note_h

    # Portrait/square logo shifts title to the right
    if logo and not logo_is_wide:
        title_x1 = inner_x1 + inner_padding + logo_w + 12

    title_width = title_x2 - title_x1
    title_height = title_y2 - title_y1

    title_min_font_size: int = t_title["min_font_size"]
    title_color = tuple(t_title["color"])
    category_color = tuple(t_category["color"])
    note_color = tuple(t_note["color"])

    if overlay_title and title_height > 0 and title_width > 0:
        title_font, title_font_size = _auto_size_font(
            overlay_title, title_width, title_height,
            inner_padding=inner_padding,
            min_font_size=title_min_font_size,
            max_font_size=title_max_font_size,
            template=template,
        )

        # Category text above title (when no logo)
        if overlay_category and not logo:
            cat_font_size = max(32, min(56, int(title_font_size * category_size_ratio)))
            cat_font = _load_font(template, cat_font_size, bold=False)
            cat_w, _cat_h = _measure_text(overlay_category, cat_font)
            cat_x = inner_x1 + (inner_width - cat_w) // 2
            cat_y = inner_y1 + inner_padding
            _draw_text_with_shadow(draw, (cat_x, cat_y), overlay_category, cat_font,
                                   category_color, shadow_offset=shadow_offset,
                                   shadow_alpha=shadow_alpha)

        # Wide logo centered above title
        if logo and logo_is_wide:
            logo_x = inner_x1 + (inner_width - logo_w) // 2
            logo_y = inner_y1 + inner_padding
            _paste_png_with_shadow(poster, logo, logo_x, logo_y,
                                   shadow_offset=shadow_offset, shadow_alpha=shadow_alpha)
            draw = ImageDraw.Draw(poster)

        # Portrait/square logo left of title
        if logo and not logo_is_wide:
            logo_x = inner_x1 + inner_padding
            logo_y = title_y1 + (title_height - logo_h) // 2
            _paste_png_with_shadow(poster, logo, logo_x, logo_y,
                                   shadow_offset=shadow_offset, shadow_alpha=shadow_alpha)
            draw = ImageDraw.Draw(poster)

        _render_title_centered(
            draw, overlay_title,
            title_x1, title_y1, title_width, title_height,
            title_font,
            inner_padding=inner_padding,
            shadow_offset=shadow_offset,
            shadow_alpha=shadow_alpha,
        )

    # ── Note (bottom-right) and badges (bottom-left) ─────────────────────────
    if overlay_note or badges:
        from .badges import render_badges_on_image

        note_font = _load_font(template, note_font_size, bold=False)

        # Vertical positioning: centre both note text and badges within the row
        row_top = inner_y2 - inner_padding - row_height
        note_y = row_top + (row_height - note_font_size) // 2
        badge_y_center = row_top + row_height // 2

        # Render badges at bottom-left if present
        badges_width = 0
        if badges:
            t_badges = template.get("badges", {})
            badge_height: int = t_badges.get("height", 36)
            badge_spacing: int = t_badges.get("spacing", 6)
            badge_margin_left: int = t_badges.get("margin_left", 16)
            badge_x = inner_x1 + badge_margin_left
            badges_width = render_badges_on_image(
                poster, badges, badge_x, badge_y_center,
                max_width=(inner_x2 - inner_x1) // 2,
                badge_height=badge_height,
                spacing=badge_spacing,
            )
            draw = ImageDraw.Draw(poster)

        if overlay_note:
            # Calculate available width for note text (reduced by badges in text area)
            inner_width = inner_x2 - inner_x1
            available_note_width = inner_width - badges_width - 2 * inner_padding

            # Wrap note text to fit available width
            note_lines = _wrap_text_to_width(overlay_note, note_font, available_note_width)
            line_height = note_font_size + 4

            # Render note lines centered horizontally
            for i, line in enumerate(reversed(note_lines)):
                line_note_w, _ = _measure_text(line, note_font)
                line_note_x = inner_x1 + (inner_width - line_note_w) // 2
                line_note_y = note_y - i * line_height
                _draw_text_with_shadow(
                    draw, (line_note_x, line_note_y), line, note_font,
                    note_color, shadow_offset=shadow_offset, shadow_alpha=shadow_alpha,
                )



def _paste_png_with_shadow(
    dest: Image.Image,
    logo: Image.Image,
    x: int,
    y: int,
    shadow_offset: tuple[int, int] = (2, 2),
    shadow_alpha: int = 128,
) -> None:
    """Paste a PNG (with alpha) onto *dest* at (*x*, *y*) with a drop shadow."""
    # Drop shadow: offset (2,2), 50% black
    shadow_layer = Image.new("RGBA", dest.size, (0, 0, 0, 0))
    shadow_layer.paste((0, 0, 0, shadow_alpha), (x + shadow_offset[0], y + shadow_offset[1],
                                        x + shadow_offset[0] + logo.width,
                                        y + shadow_offset[1] + logo.height),
                       mask=logo.split()[3])
    dest.paste(
        Image.alpha_composite(dest.convert("RGBA"), shadow_layer).convert("RGB"),
    )
    # Paste actual logo
    dest.paste(logo, (x, y), mask=logo.split()[3])

def compose_poster(
    frame_path: str,
    crop_position: str,
    overlay_title: str | None,
    output_path: str,
    overlay_category: str | None = None,
    overlay_category_logo_path: str | None = None,
    overlay_note: str | None = None,
    template: dict | None = None,
    badges: list[str] | None = None,
) -> str:
    """Compose a 1080×1620 poster image (2:3 format).

    Parameters
    ----------
    frame_path:
        Path to the high-res extracted frame (e.g. 1920×1080).
    crop_position:
        One of: ``left``, ``center-left``, ``center``, ``center-right``,
        ``right``.
    overlay_title:
        Optional title text for the bottom text area.
    output_path:
        Destination path for the poster JPEG.
    overlay_category:
        Optional category label shown above the title (poster only).
    overlay_category_logo_path:
        Optional path to a PNG logo shown instead of category text (poster only).
        Wide logos (width > height*1.3) are centered above the title;
        square/portrait logos appear left of the title.
    overlay_note:
        Optional small text shown at the bottom-right of the text area (poster only).
    template:
        Optional design template dict (from :func:`poster_template.load_template`).
        Uses built-in defaults when ``None``.
    badges:
        Optional list of badge type strings (e.g. ``["4k", "hdr"]``) to render at
        the bottom-left of the text area. Only applies to poster format.

    Returns
    -------
    The absolute path of the created poster image.
    """
    t = template if template is not None else copy.deepcopy(_BUILTIN_TEMPLATE)

    t_poster = t["poster"]
    t_sep = t["separator"]
    t_frame = t["frame"]

    poster_width: int = t_poster["width"]
    poster_height: int = t_poster["height"]
    poster_square: int = t_poster["image_height"]
    poster_text_height: int = t_poster["text_area_height"]
    vignette_strength: float = t_poster["vignette_strength"]
    quality: int = t_poster["quality"]

    # text_area settings
    t_text_area = t.get("text_area", {})
    text_area_bg_color = tuple(t_text_area.get("background_color", [26, 26, 26]))

    separator_height: int = t_sep["height"]
    separator_color = tuple(t_sep["color"])

    frame_margin: int = t_frame["margin"]
    frame_border: int = t_frame["border_width"]
    frame_color = tuple(t_frame["color"])
    corner_radius: int = t_frame.get("corner_radius", 0)

    t_shadow = t["shadow"]
    shadow_offset = (t_shadow["offset_x"], t_shadow["offset_y"])
    shadow_alpha: int = t_shadow["alpha"]

    with Image.open(frame_path) as src:
        src = src.convert("RGB")
        src_width, src_height = src.size

        # 1. Crop to 1:1 square and scale to poster width
        crop_box = apply_crop_position(src_width, src_height, crop_position)
        square = src.crop(crop_box).resize((poster_width, poster_square), Image.LANCZOS)

        # 2. Apply subtle vignette to the square image
        square = _apply_vignette(square, vignette_strength)

        # 2b. Category header overlay at top of image (when category_at_top=True)
        category_at_top: bool = t_poster.get("category_at_top", False)
        category_header_height: int = t_poster.get("category_header_height", 80)
        overlay_category_for_text = overlay_category  # may be cleared below

        if category_at_top and overlay_category:
            t_cat = t["category"]
            header_alpha: int = t_cat.get("header_background_alpha", 180)
            header_bg = Image.new("RGBA", (poster_width, category_header_height), (0, 0, 0, header_alpha))
            sq_rgba = square.convert("RGBA")
            sq_rgba.paste(header_bg, (0, 0), mask=header_bg)
            square = sq_rgba.convert("RGB")
            draw_sq = ImageDraw.Draw(square)
            cat_font = _load_font(t, t_cat.get("header_font_size", 42), bold=True)
            cat_color = tuple(t_cat["color"])
            cat_w, cat_h = _measure_text(overlay_category, cat_font)
            cat_x = (poster_width - cat_w) // 2
            cat_y = (category_header_height - cat_h) // 2
            _draw_text_with_shadow(
                draw_sq, (cat_x, cat_y), overlay_category, cat_font, cat_color,
                shadow_offset=shadow_offset, shadow_alpha=shadow_alpha,
            )
            overlay_category_for_text = None  # already rendered; skip in text area

        # 2c. Badges overlaid on bottom-left of preview image (when badges_on_image=True)
        badges_on_image: bool = t_poster.get("badges_on_image", False)
        badges_for_text_area = badges  # default: render badges in text area

        if badges_on_image and badges:
            from .badges import render_badges_on_image
            t_badges_img = t["badges"]
            badge_height_img: int = t_badges_img.get("height", 36)
            badge_spacing_img: int = t_badges_img.get("spacing", 6)
            badge_margin_bottom_img: int = t_badges_img.get("margin_bottom", 12)
            # Align badge left edge with the text area's inner content edge
            badge_x = frame_margin + frame_border + t_frame["inner_padding"]
            badge_y_center = poster_square - badge_margin_bottom_img - badge_height_img // 2
            render_badges_on_image(
                square, badges, badge_x, badge_y_center,
                max_width=poster_width // 2,
                badge_height=badge_height_img,
                spacing=badge_spacing_img,
            )
            badges_for_text_area = None  # already rendered on image

        # 3. Solid dark background for the text area
        text_area_bg = Image.new("RGB", (poster_width, poster_text_height), text_area_bg_color)

        # 4. Compose poster canvas
        poster = Image.new("RGB", (poster_width, poster_height))
        poster.paste(square, (0, 0))
        poster.paste(text_area_bg, (0, poster_square))

        draw = ImageDraw.Draw(poster)

        # 5. Separator line at y=poster_square (skipped when height is 0)
        if separator_height > 0:
            for dy in range(separator_height):
                draw.line(
                    [(0, poster_square + dy), (poster_width - 1, poster_square + dy)],
                    fill=separator_color,
                )

        # 6. Frame border with rounded corners, below separator
        frame_x1 = frame_margin
        frame_y1 = poster_square + separator_height + frame_margin
        frame_x2 = poster_width - frame_margin
        frame_y2 = poster_height - frame_margin
        if frame_border > 0:
            draw.rounded_rectangle(
                [frame_x1, frame_y1, frame_x2, frame_y2],
                radius=corner_radius,
                outline=frame_color,
                width=frame_border,
            )

        # 7. Content inside frame
        inner_x1 = frame_x1 + frame_border
        inner_y1 = frame_y1 + frame_border
        inner_x2 = frame_x2 - frame_border
        inner_y2 = frame_y2 - frame_border

        # 7a. Background inside the frame (blurred video or solid matte colour)
        inner_w = inner_x2 - inner_x1
        inner_h = inner_y2 - inner_y1
        inner_radius = max(0, corner_radius - frame_border)

        inner_mask = Image.new("L", (inner_w, inner_h), 0)
        inner_mask_draw = ImageDraw.Draw(inner_mask)
        inner_mask_draw.rounded_rectangle(
            [0, 0, inner_w - 1, inner_h - 1], radius=inner_radius, fill=255
        )

        blur_enabled: bool = t_text_area.get("blur_enabled", True)
        if blur_enabled:
            inner_blur_radius: int = t_text_area.get("blur_radius", t_poster.get("blur_radius", 30))
            inner_darken: float = t_text_area.get("darken_opacity", 0.4)
            blurred_inner = square.resize((inner_w, inner_h), Image.LANCZOS)
            blurred_inner = blurred_inner.filter(ImageFilter.GaussianBlur(inner_blur_radius))
            if inner_darken > 0:
                dark_overlay = Image.new("RGBA", blurred_inner.size, (0, 0, 0, int(255 * inner_darken)))
                blurred_rgba = blurred_inner.convert("RGBA")
                blurred_rgba = Image.alpha_composite(blurred_rgba, dark_overlay)
                blurred_inner = blurred_rgba.convert("RGB")
            poster.paste(blurred_inner, (inner_x1, inner_y1), mask=inner_mask)
        else:
            # Solid matte background — timeless and clean
            inner_bg_color = tuple(t_text_area.get("inner_background_color", list(text_area_bg_color)))
            inner_bg = Image.new("RGB", (inner_w, inner_h), inner_bg_color)
            poster.paste(inner_bg, (inner_x1, inner_y1), mask=inner_mask)

        if overlay_title or overlay_note or badges:
            _render_poster_text_area(
                poster,
                inner_x1, inner_y1, inner_x2, inner_y2,
                overlay_title, overlay_category_for_text, overlay_category_logo_path, overlay_note,
                t,
                badges=badges_for_text_area,
            )

        poster.save(output_path, "JPEG", quality=quality)
        return os.path.abspath(output_path)


def _render_text_centered(
    draw: ImageDraw.ImageDraw,
    text: str,
    area_x: int,
    area_y: int,
    area_width: int,
    area_height: int,
    font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
    text_padding: int = 60,
) -> None:
    """Render *text* centered (horizontally and vertically) within an area."""
    lines = _wrap_text_to_width(text, font, area_width - 2 * text_padding)

    dummy = Image.new("RGB", (1, 1))
    dummy_draw = ImageDraw.Draw(dummy)

    line_spacing = 8
    line_heights = [
        dummy_draw.textbbox((0, 0), line, font=font)[3]
        - dummy_draw.textbbox((0, 0), line, font=font)[1]
        for line in lines
    ]
    total_height = sum(line_heights) + line_spacing * (len(lines) - 1)

    y = area_y + (area_height - total_height) // 2

    for i, line in enumerate(lines):
        bbox = dummy_draw.textbbox((0, 0), line, font=font)
        line_width = bbox[2] - bbox[0]
        x = area_x + (area_width - line_width) // 2
        draw.text((x, y), line, fill=(255, 255, 255), font=font)
        y += line_heights[i] + line_spacing


def compose_landscape_with_text(
    frame_path: str,
    overlay_title: str,
    output_path: str,
    template: dict | None = None,
) -> str:
    """Compose a 1920×1080 landscape image with text overlay in the lower third.

    Parameters
    ----------
    frame_path:
        Path to the high-res extracted frame.
    overlay_title:
        Text to render in the lower third of the image.
    output_path:
        Destination path for the output JPEG.
    template:
        Optional design template dict (from :func:`poster_template.load_template`).
        Uses built-in defaults when ``None``.

    Returns
    -------
    The absolute path of the created image.
    """
    t = template if template is not None else copy.deepcopy(_BUILTIN_TEMPLATE)

    t_land = t["landscape"]
    landscape_width: int = t_land["width"]
    landscape_height: int = t_land["height"]
    lower_third_height: int = t_land["lower_third_height"]
    gradient_max_alpha: int = t_land["gradient_max_alpha"]
    text_padding: int = t_land["text_padding"]
    min_font_size: int = t_land["min_font_size"]
    max_font_size: int = t_land["max_font_size"]
    quality: int = t["poster"]["quality"]

    with Image.open(frame_path) as src:
        src = src.convert("RGB")
        src = src.resize((landscape_width, landscape_height), Image.LANCZOS)

        # Dark semi-transparent gradient for the lower third
        overlay = Image.new("RGBA", (landscape_width, landscape_height), (0, 0, 0, 0))
        draw_overlay = ImageDraw.Draw(overlay)
        lower_third_y = landscape_height - lower_third_height

        for y in range(lower_third_height):
            alpha = int(gradient_max_alpha * y / lower_third_height)
            draw_overlay.line(
                [(0, lower_third_y + y), (landscape_width, lower_third_y + y)],
                fill=(0, 0, 0, alpha),
            )

        composited = Image.alpha_composite(src.convert("RGBA"), overlay).convert("RGB")

        font, _ = _auto_size_font(
            overlay_title,
            landscape_width,
            lower_third_height - 2 * text_padding,  # subtract vertical padding from available height
            inner_padding=text_padding,  # text_padding controls both horizontal and vertical margins
            min_font_size=min_font_size,
            max_font_size=max_font_size,
        )
        draw = ImageDraw.Draw(composited)
        _render_text_centered(
            draw, overlay_title,
            0, lower_third_y, landscape_width, lower_third_height,
            font,
            text_padding=text_padding,
        )

        composited.save(output_path, "JPEG", quality=quality)
        return os.path.abspath(output_path)
