import html
import math
import re
import threading
import unicodedata
from pathlib import Path

from PIL import Image, ImageChops, ImageDraw, ImageFont

from .assets import meta_unit_scale
from .settings import (
    BASE_CARD_SIZE,
    BOLD_OFFSETS,
    DECK_GROUPS,
    FIXED_CROP,
    FLAVOUR_BLOCK_BOTTOM_SHIFT_CROPPED_RATIO,
    FONTS_DIR,
    ITALIC_SHEAR,
    TEXT_AREA_BOTTOM_CROPPED_RATIO,
    TEXT_AREA_TOP_CROPPED_RATIOS,
    TEXT_INDENT_CHARS,
    TITLE_WIDTH_LIMITS,
)


class Fonts:
    def __init__(self) -> None:
        self._xirod = FONTS_DIR / "xirod.ttf"
        self._consolas = FONTS_DIR / "consolas.ttf"
        self._unicode = Path("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf")
        self._cache: dict[tuple[str, int], ImageFont.FreeTypeFont | ImageFont.ImageFont] = {}
        self._lock = threading.Lock()

    def title(self, java_size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
        size = max(8, int(java_size / 3) * 2)
        return self._font("xirod", self._xirod, size)

    def text(self, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
        return self._font("consolas", self._consolas, max(8, size))

    def unicode_text(self, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
        return self._font("unicode", self._unicode, max(8, size))

    def _font(self, name: str, path: Path, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
        key = (name, size)
        with self._lock:
            if key not in self._cache:
                if path.exists():
                    self._cache[key] = ImageFont.truetype(str(path), size=size, layout_engine=ImageFont.Layout.RAQM)
                else:
                    self._cache[key] = ImageFont.load_default()
        return self._cache[key]


FONTS = Fonts()

KEYWORDS = [
    "Voice",
    "Mind",
    "Will",
    r"\+[0-9] Cards?",
    r"\+[0-9] Encounters?",
    r"\+[0-9] Blocks?",
    r"\+[0-9] Money",
    r"Effort [0-9]",
    "Blocks?",
    "Money",
    r"\+X",
    r"\-X",
    r"\+[0-9]",
    "-[0-9]",
    r"Reboot [0-9]?",
    r"Reveal [0-9]?",
    "Revealed",
    r"Distract [0-9]?",
    "Distracted",
    "Deleted",
    "Delete",
    "Draw [0-9] office cards",
    "Draw [0-9] office card",
    "Draw [0-9] basement cards",
    "Draw [0-9] basement card",
    "Draw [0-9] encounter cards",
    "Draw [0-9] encounter card",
    r"Escape [0-9]?",
    r"Escape&nbsp;[0-9]?",
    r"Extract [0-9]?",
    r"Browse [0-9]",
    "Infiltrating",
    "Infiltrates",
    "Infiltrated?",
    "Hacked",
    "Hacking",
    "Hack any company",
    "Hacks?",
    "Manipulates?",
    "Demoted",
    "Demote",
    "Promoted",
    "Promote",
    "Transferring",
    "Transferred",
    "Transfer",
]
KEYWORD_RE = re.compile(r"(?i)(?<!<b>)(?<![\w+.-])(" + "|".join(KEYWORDS) + r")(?![\w.-])(?!</b>)")


def format_card_text(text: str | None) -> str:
    if text is None:
        return ""
    lines: list[str] = []
    for original in text.split("\n"):
        leading = re.match(r"^\s*", original).group(0)
        line = original.strip()
        if line.startswith("//"):
            lines.append(leading + re.sub(r"//( ?)(?!<i>)(.*)(?!</i>)", r"<i>//\1\2</i>", line))
            continue

        parts = line.split("//", 1)
        action = parts[0]
        if not action.strip().endswith(":"):
            action = re.sub(r" *$", ";", action)
        action = action.replace(";;", "; ").replace(":;", ":")
        action = KEYWORD_RE.sub(r"<b>\1</b>", action)
        result = leading + action
        if len(parts) > 1:
            comment = "//" + parts[1]
            comment = re.sub(r"//( ?)(?!<i>)(.*)(?!</i>)", r"<i>//\1\2</i>", comment)
            result += " " + comment
        lines.append(result)
    return "\n".join(lines)


def format_flavour_text(text: str | None) -> str:
    if not text:
        return ""
    zalgo = re.fullmatch(r"<zalgo>(.*)</zalgo>", text, flags=re.DOTALL)
    if zalgo:
        return "<i>" + zalgo.group(1) + "</i>\n<i></i>"
    if text.startswith("<i>"):
        return text
    text = re.sub(r"/\* *", "/*&nbsp;", text)
    text = re.sub(r" *\*/", "&nbsp;*/", text)
    return "<i>" + text.replace("\n", "</i>\n<i>") + "</i>"


def strip_font_tags(text: str) -> str:
    text = re.sub(r"</?(font|p)[^>]*>", "", text)
    text = re.sub(r"<hr\s*/?>", "\n<hr>\n", text)
    text = text.replace("<br>", "\n").replace("<br/>", "\n")
    return html.unescape(text)


def parse_runs(text: str) -> list[tuple[str, set[str]]]:
    text = strip_font_tags(text)
    token_re = re.compile(r"(<\/?b>|<\/?i>|<hr>)", re.I)
    parts = token_re.split(text)
    styles: set[str] = set()
    runs: list[tuple[str, set[str]]] = []
    for part in parts:
        low = part.lower()
        if low == "<b>":
            styles.add("b")
        elif low == "</b>":
            styles.discard("b")
        elif low == "<i>":
            styles.add("i")
        elif low == "</i>":
            styles.discard("i")
        elif low == "<hr>":
            runs.append(("\n---\n", set(styles)))
        elif part:
            runs.append((part, set(styles)))
    return runs


def split_words(runs: list[tuple[str, set[str]]]) -> list[tuple[str, set[str]]]:
    out: list[tuple[str, set[str]]] = []
    for text, styles in runs:
        for token in re.findall(r"\n|[ \t\r\f\v]+|[^ \t\r\f\v\n]+", text):
            out.append((token, styles))
    return out


def font_px(font: ImageFont.ImageFont) -> int:
    return int(getattr(font, "size", 16))


def cluster_has_unicode(cluster: str) -> bool:
    return any(ord(char) > 127 or unicodedata.combining(char) for char in cluster)


def text_clusters(text: str) -> list[str]:
    clusters: list[str] = []
    for char in text:
        if clusters and unicodedata.combining(char):
            clusters[-1] += char
        else:
            clusters.append(char)
    return clusters


def text_segments(text: str, font: ImageFont.ImageFont) -> list[tuple[str, ImageFont.ImageFont]]:
    segments: list[tuple[str, ImageFont.ImageFont]] = []
    current = ""
    current_font = font
    unicode_font = FONTS.unicode_text(font_px(font))
    for cluster in text_clusters(text):
        segment_font = unicode_font if cluster_has_unicode(cluster) else font
        if current and segment_font is current_font:
            current += cluster
        else:
            if current:
                segments.append((current, current_font))
            current = cluster
            current_font = segment_font
    if current:
        segments.append((current, current_font))
    return segments


def cluster_layout_text(cluster: str) -> str:
    if any(unicodedata.combining(char) for char in cluster):
        return "".join(char for char in cluster if not unicodedata.combining(char))
    return cluster


def is_zalgo_cluster(cluster: str) -> bool:
    return sum(1 for char in cluster if unicodedata.combining(char)) >= 4


def zalgo_extra_advance(font: ImageFont.ImageFont, mark_count: int) -> int:
    if mark_count <= 0:
        return 0
    return max(2, round(math.sqrt(mark_count) * font_px(font) * 0.065))


def text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, styles: set[str] | None = None) -> int:
    if not text:
        return 0
    styles = styles or set()
    bold_offset = max(dx for dx, _ in BOLD_OFFSETS) if "b" in styles else 0
    width = 0.0
    for segment, segment_font in text_segments(text, font):
        for cluster in text_clusters(segment):
            layout_text = cluster_layout_text(cluster)
            if layout_text:
                width += draw.textlength(layout_text, font=segment_font)
                if is_zalgo_cluster(cluster):
                    mark_count = sum(1 for char in cluster if unicodedata.combining(char))
                    width += zalgo_extra_advance(segment_font, mark_count)
    if bold_offset:
        width += bold_offset
    return round(width)


def text_line_height(font: ImageFont.ImageFont) -> int:
    if hasattr(font, "getmetrics"):
        ascent, descent = font.getmetrics()
        return max(1, ascent + descent + 2)
    return max(1, font_px(font) + 2)


TRIMMED_CARD_WIDTHS: dict[Path, int] = {}


def trimmed_card_width(deck_dir: Path) -> int:
    deck_dir = deck_dir.resolve()
    if deck_dir not in TRIMMED_CARD_WIDTHS:
        TRIMMED_CARD_WIDTHS[deck_dir] = BASE_CARD_SIZE[0] - FIXED_CROP[0] - FIXED_CROP[2]
    return TRIMMED_CARD_WIDTHS[deck_dir]


def title_width_limit(deck_dir: Path, meta: dict) -> int:
    group = DECK_GROUPS.get(deck_dir.name, "")
    ratio = TITLE_WIDTH_LIMITS.get(group, 0.94)
    crop_scale = meta_unit_scale(meta)
    return math.floor(trimmed_card_width(deck_dir) * crop_scale * ratio)


def title_font_for_target_width(title: str, meta: dict, font_scale: float, max_width: int) -> ImageFont.ImageFont:
    source_base_size = int(meta.get("titleFontSize") or meta.get("fontSize") or 32)
    base_size = max(8, round(source_base_size * font_scale))
    min_size = max(8, round(base_size * 0.55))
    draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
    for size in range(base_size, min_size - 1, -1):
        font = FONTS.title(size)
        if draw.textlength(title, font=font) <= max_width:
            return font
    return FONTS.title(min_size)


def standard_text_layout(meta: dict, target_size: tuple[int, int], crop_edges: bool) -> dict:
    card_w = int(meta["cardWidth"])
    card_h = int(meta["cardHeight"])
    unit = meta_unit_scale(meta)
    if crop_edges:
        left = round(FIXED_CROP[0] * unit)
        top = round(FIXED_CROP[1] * unit)
        right = round(FIXED_CROP[2] * unit)
        bottom = round(FIXED_CROP[3] * unit)
    else:
        left = top = right = bottom = 0
    visible_w = max(1, card_w - left - right)
    visible_h = max(1, card_h - top - bottom)
    return {
        "left": left,
        "top": top,
        "sx": target_size[0] / visible_w,
        "sy": target_size[1] / visible_h,
        "target_w": target_size[0],
        "target_h": target_size[1],
    }


def tx(layout: dict, x: float) -> int:
    return round((x - layout["left"]) * layout["sx"])


def ty(layout: dict, y: float) -> int:
    return round((y - layout["top"]) * layout["sy"])


def tw(layout: dict, width: float) -> int:
    return max(1, round(width * layout["sx"]))


def th(layout: dict, height: float) -> int:
    return max(1, round(height * layout["sy"]))


def font_size_y(layout: dict, source_size: float) -> int:
    return max(8, round(source_size * layout["sy"]))


def source_y_from_cropped_ratio(meta: dict, ratio: float) -> float:
    unit = meta_unit_scale(meta)
    crop_top = FIXED_CROP[1] * unit
    crop_bottom = FIXED_CROP[3] * unit
    cropped_h = float(meta["cardHeight"]) - crop_top - crop_bottom
    return crop_top + cropped_h * ratio


def transformed_text_area(deck_dir: Path, meta: dict, layout: dict) -> tuple[int, int, int]:
    group = DECK_GROUPS.get(deck_dir.name, "")
    top_ratio = TEXT_AREA_TOP_CROPPED_RATIOS.get(group, TEXT_AREA_TOP_CROPPED_RATIOS["encounter"])
    source_top = source_y_from_cropped_ratio(meta, top_ratio)
    source_bottom = source_y_from_cropped_ratio(meta, TEXT_AREA_BOTTOM_CROPPED_RATIO)

    previous_bottom = int(meta["textOffset"]) + int(meta["textHeight"]) - int(meta["textPadding"])
    flavour_content_bottom = previous_bottom + (
        source_y_from_cropped_ratio(meta, FLAVOUR_BLOCK_BOTTOM_SHIFT_CROPPED_RATIO)
        - source_y_from_cropped_ratio(meta, 0.0)
    )
    source_bottom_gap = max(0.0, source_bottom - flavour_content_bottom)
    return ty(layout, source_top), ty(layout, source_bottom), th(layout, source_bottom_gap)


def draw_styled_text(
    image: Image.Image,
    xy: tuple[int, int],
    text: str,
    font: ImageFont.ImageFont,
    color: tuple[int, int, int],
    styles: set[str],
    line_h: int,
) -> None:
    if not text:
        return
    ascent = font.getmetrics()[0] if hasattr(font, "getmetrics") else font_px(font)
    bold_offsets = BOLD_OFFSETS if "b" in styles else ()

    def draw_zalgo_cluster(
        target: Image.Image,
        target_draw: ImageDraw.ImageDraw,
        pos: tuple[int, int],
        cluster: str,
        segment_font: ImageFont.ImageFont,
        fill: tuple[int, int, int],
    ) -> int:
        base = cluster_layout_text(cluster) or " "
        marks = [char for char in cluster if unicodedata.combining(char)]
        base_font = font if base.isascii() else segment_font
        base_w = max(1, round(target_draw.textlength(base, font=base_font)))
        advance_w = base_w + zalgo_extra_advance(base_font, len(marks))

        def draw_base(base_pos: tuple[int, int]) -> None:
            if "i" not in styles:
                target_draw.text(base_pos, base, fill=fill, font=base_font, anchor="ls")
                return
            base_ascent = base_font.getmetrics()[0] if hasattr(base_font, "getmetrics") else font_px(base_font)
            pad = max(4, round(font_px(base_font) * 0.35))
            src_w = base_w + pad * 2
            src_h = font_px(base_font) * 3
            src = Image.new("RGBA", (src_w, src_h), (0, 0, 0, 0))
            src_draw = ImageDraw.Draw(src)
            src_draw.text((pad, pad + base_ascent), base, fill=(*fill, 255), font=base_font, anchor="ls")
            skew_w = src.width + round(src.height * ITALIC_SHEAR)
            skewed = src.transform((skew_w, src.height), Image.Transform.AFFINE, (1, ITALIC_SHEAR, -round(src.height * ITALIC_SHEAR), 0, 1, 0), Image.Resampling.BICUBIC)
            bbox = skewed.getbbox()
            if not bbox:
                return
            skewed = skewed.crop(bbox)
            paste_at = (base_pos[0], base_pos[1] - pad - base_ascent + bbox[1])
            if target.mode == "RGBA":
                target.alpha_composite(skewed, paste_at)
            else:
                target.paste(skewed.convert("RGB"), paste_at, skewed.getchannel("A"))

        draw_base(pos)
        mark_font = FONTS.unicode_text(font_px(segment_font))
        mark_ascent = mark_font.getmetrics()[0] if hasattr(mark_font, "getmetrics") else font_px(mark_font)
        above = 0
        below = 0
        step = max(2, round(font_px(segment_font) * 0.12))
        center_x = pos[0] + base_w // 2
        for mark in marks:
            combining = unicodedata.combining(mark)
            if combining in {0, 1, 202, 220, 240}:
                below += 1
                y = pos[1] + below * step
            else:
                above += 1
                y = pos[1] - above * step

            # Rendering a combining mark alone produces a dotted-circle placeholder
            # in many fonts. Render it attached to its base, subtract the base glyph,
            # then place the extracted mark so it can spill outside the text line.
            pad = max(8, font_px(segment_font) * 2)
            patch_size = max(32, font_px(segment_font) * 5)
            full = Image.new("L", (patch_size, patch_size), 0)
            base_mask = Image.new("L", (patch_size, patch_size), 0)
            full_draw = ImageDraw.Draw(full)
            base_draw = ImageDraw.Draw(base_mask)
            base_pos = (pad, pad + mark_ascent)
            full_draw.text(base_pos, base + mark, fill=255, font=mark_font, anchor="ls")
            base_draw.text(base_pos, base, fill=255, font=mark_font, anchor="ls")
            mark_mask = ImageChops.subtract(full, base_mask)
            bbox = mark_mask.getbbox()
            if bbox:
                mark_crop = mark_mask.crop(bbox)
                mark_img = Image.new("RGBA", mark_crop.size, (*fill, 0))
                mark_img.putalpha(mark_crop)
                paste_at = (round(center_x - mark_crop.width / 2), round(y - mark_crop.height / 2))
                if target.mode == "RGBA":
                    target.alpha_composite(mark_img, paste_at)
                else:
                    target.paste(mark_img.convert("RGB"), paste_at, mark_img.getchannel("A"))
        return advance_w

    def draw_run(target: Image.Image, pos: tuple[int, int]) -> None:
        target_draw = ImageDraw.Draw(target)
        cursor_x = pos[0]
        for segment, segment_font in text_segments(text, font):
            for cluster in text_clusters(segment):
                if is_zalgo_cluster(cluster):
                    advance = draw_zalgo_cluster(target, target_draw, (cursor_x, pos[1]), cluster, segment_font, color)
                    for dx, dy in bold_offsets:
                        draw_zalgo_cluster(target, target_draw, (cursor_x + dx, pos[1] + dy), cluster, segment_font, color)
                    cursor_x += advance
                else:
                    target_draw.text((cursor_x, pos[1]), cluster, fill=color, font=segment_font, anchor="ls")
                    for dx, dy in bold_offsets:
                        target_draw.text((cursor_x + dx, pos[1] + dy), cluster, fill=color, font=segment_font, anchor="ls")
                    cursor_x += text_width(target_draw, cluster, font, set())

    has_zalgo = any(is_zalgo_cluster(cluster) for cluster in text_clusters(text))
    if "i" not in styles or has_zalgo:
        draw_run(image, (xy[0], xy[1] + ascent))
        return

    width = max(1, text_width(ImageDraw.Draw(image), text, font, styles))
    pad = max(4, round(font_px(font) * 2.4))
    src = Image.new("RGBA", (width + pad * 2, line_h + pad * 2), (0, 0, 0, 0))
    draw_run(src, (pad, pad + ascent))

    skew_w = src.width + round(src.height * ITALIC_SHEAR)
    skewed = src.transform((skew_w, src.height), Image.Transform.AFFINE, (1, ITALIC_SHEAR, -round(src.height * ITALIC_SHEAR), 0, 1, 0), Image.Resampling.BICUBIC)
    bbox = skewed.getbbox()
    if not bbox:
        return
    skewed = skewed.crop(bbox)
    paste_at = (xy[0], xy[1] - pad + bbox[1])
    if image.mode == "RGBA":
        image.alpha_composite(skewed, paste_at)
    else:
        image.paste(skewed.convert("RGB"), paste_at, skewed.getchannel("A"))


def wrap_runs(
    draw: ImageDraw.ImageDraw,
    runs: list[tuple[str, set[str]]],
    font: ImageFont.ImageFont,
    max_width: int,
) -> list[list[tuple[str, set[str]]]]:
    lines: list[list[tuple[str, set[str]]]] = []
    current: list[tuple[str, set[str]]] = []
    current_w = 0
    for token, styles in split_words(runs):
        if token == "\n":
            lines.append(current)
            current = []
            current_w = 0
            continue
        if token.isspace():
            if current:
                current.append((token, styles))
                current_w += text_width(draw, token, font, styles)
            continue
        token_w = text_width(draw, token, font, styles)
        should_wrap = current_w + token_w > max_width
        if current and should_wrap:
            while current and current[-1][0].isspace():
                current.pop()
            lines.append(current)
            current = []
            current_w = 0
        current.append((token, styles))
        current_w += token_w
    if current or not lines:
        while current and current[-1][0].isspace():
            current.pop()
        lines.append(current)
    return lines


def draw_text_box(
    image: Image.Image,
    text: str,
    box: tuple[int, int, int, int],
    font: ImageFont.ImageFont,
    color: tuple[int, int, int],
    align_x: float,
    align_y: float,
    paragraph_margin: int = 10,
    indent_chars: int = TEXT_INDENT_CHARS,
    spacing_scale: float = 1.0,
) -> int:
    draw = ImageDraw.Draw(image)
    x, y, width, height = box
    paragraphs = text.split("\n")
    paragraph_lines: list[tuple[list[list[tuple[str, set[str]]]], int]] = []
    total_h = 0
    line_h = max(1, round(text_line_height(font) * spacing_scale))
    paragraph_margin = max(0, round(paragraph_margin * spacing_scale))
    indent_px = text_width(draw, " " * indent_chars, font, set())

    for paragraph in paragraphs:
        indent = indent_px if paragraph.startswith("\t") else 0
        paragraph = paragraph.lstrip("\t")
        if paragraph == "---":
            lines = [[("---", set())]]
        else:
            lines = wrap_runs(draw, parse_runs(paragraph), font, max(1, width - indent))
        paragraph_lines.append((lines, indent))
        total_h += len(lines) * line_h + paragraph_margin
    if paragraph_lines:
        total_h -= paragraph_margin

    cursor_y = y + int(height * align_y - total_h * align_y)
    for lines, indent in paragraph_lines:
        for line in lines:
            if len(line) == 1 and line[0][0] == "---":
                draw.line((x, cursor_y + line_h // 2, x + width, cursor_y + line_h // 2), fill=color, width=2)
                cursor_y += line_h
                continue
            line_text_w = sum(text_width(draw, run_text, font, styles) for run_text, styles in line)
            line_width = max(1, width - indent)
            cursor_x = x + indent + int(line_width * align_x - line_text_w * align_x)
            for run_text, styles in line:
                if not run_text:
                    continue
                draw_styled_text(image, (cursor_x, cursor_y), run_text, font, color, styles, max(line_h, text_line_height(font)))
                cursor_x += text_width(draw, run_text, font, styles)
            cursor_y += line_h
        cursor_y += paragraph_margin
    return total_h


def measure_wrapped_text_box(
    image: Image.Image,
    text: str,
    font: ImageFont.ImageFont,
    width: int,
    paragraph_margin: int = 10,
    indent_chars: int = TEXT_INDENT_CHARS,
    spacing_scale: float = 1.0,
) -> tuple[list[tuple[list[list[tuple[str, set[str]]]], int]], int, int]:
    draw = ImageDraw.Draw(image)
    paragraphs = text.split("\n")
    paragraph_lines: list[tuple[list[list[tuple[str, set[str]]]], int]] = []
    total_h = 0
    line_h = max(1, round(text_line_height(font) * spacing_scale))
    paragraph_margin = max(0, round(paragraph_margin * spacing_scale))
    indent_px = text_width(draw, " " * indent_chars, font, set())
    for paragraph in paragraphs:
        indent = indent_px if paragraph.startswith("\t") else 0
        paragraph = paragraph.lstrip("\t")
        lines = wrap_runs(draw, parse_runs(paragraph), font, max(1, width - indent))
        paragraph_lines.append((lines, indent))
        total_h += len(lines) * line_h + paragraph_margin
    if paragraph_lines:
        total_h -= paragraph_margin
    return paragraph_lines, total_h, line_h


def draw_lines(
    image: Image.Image,
    paragraph_lines: list[tuple[list[list[tuple[str, set[str]]]], int]],
    x: int,
    y: int,
    width: int,
    font: ImageFont.ImageFont,
    color: tuple[int, int, int],
    line_h: int,
    paragraph_margin: int = 10,
) -> None:
    draw = ImageDraw.Draw(image)
    cursor_y = y
    for lines, indent in paragraph_lines:
        for line in lines:
            cursor_x = x + indent
            for run_text, styles in line:
                if not run_text:
                    continue
                if len(line) == 1 and run_text == "---":
                    draw.line((x, cursor_y + line_h // 2, x + width, cursor_y + line_h // 2), fill=color, width=2)
                    continue
                draw_styled_text(image, (cursor_x, cursor_y), run_text, font, color, styles, max(line_h, text_line_height(font)))
                cursor_x += text_width(draw, run_text, font, styles)
            cursor_y += line_h
        cursor_y += paragraph_margin


def flavour_rule_metrics(font: ImageFont.ImageFont, spacing_scale: float = 1.0) -> tuple[int, int, int]:
    scale = max(1.0, font.size / 24.0) if hasattr(font, "size") else 1.0
    rule_gap = max(0, round(8 * scale * spacing_scale))
    rule_h = max(1, round(1 * scale))
    hook_h = max(1, round(4 * scale * spacing_scale))
    return rule_gap, rule_h, hook_h


def measure_flavour_block(
    image: Image.Image,
    flavour_text: str,
    font: ImageFont.ImageFont,
    width: int,
    paragraph_margin: int = 10,
    indent_chars: int = TEXT_INDENT_CHARS,
    spacing_scale: float = 1.0,
    bottom_gap: int = 0,
) -> dict:
    paragraph_lines, text_total_h, line_h = measure_wrapped_text_box(
        image,
        flavour_text,
        font,
        width,
        paragraph_margin,
        indent_chars,
        spacing_scale,
    )
    scaled_paragraph_margin = max(0, round(paragraph_margin * spacing_scale))
    scaled_bottom_gap = max(0, round(bottom_gap * spacing_scale))
    rule_gap, rule_h, hook_h = flavour_rule_metrics(font, spacing_scale)
    total_h = max(rule_h, hook_h) + rule_gap + text_total_h + scaled_bottom_gap
    return {
        "paragraph_lines": paragraph_lines,
        "text_total_h": text_total_h,
        "line_h": line_h,
        "paragraph_margin": scaled_paragraph_margin,
        "bottom_gap": scaled_bottom_gap,
        "rule_gap": rule_gap,
        "rule_h": rule_h,
        "hook_h": hook_h,
        "total_h": total_h,
    }


def draw_flavour_block(
    image: Image.Image,
    block: dict,
    x: int,
    top: int,
    width: int,
    font: ImageFont.ImageFont,
    color: tuple[int, int, int],
) -> None:
    draw = ImageDraw.Draw(image)
    rule_y = top
    rule_h = block["rule_h"]
    hook_h = block["hook_h"]
    draw.line((x, rule_y, x + width, rule_y), fill=color, width=rule_h)
    draw.line((x, rule_y, x, rule_y + hook_h), fill=color, width=rule_h)
    draw_lines(
        image,
        block["paragraph_lines"],
        x,
        rule_y + max(rule_h, hook_h) + block["rule_gap"],
        width,
        font,
        color,
        block["line_h"],
        block["paragraph_margin"],
    )


def draw_body_and_flavour_text(
    image: Image.Image,
    body_text: str,
    flavour_text: str,
    box: tuple[int, int, int, int],
    body_font: ImageFont.ImageFont,
    flavour_font: ImageFont.ImageFont,
    color: tuple[int, int, int],
    paragraph_margin: int,
    indent_chars: int = TEXT_INDENT_CHARS,
    flavour_bottom_gap: int = 0,
) -> None:
    x, y, width, height = box

    def measure(spacing_scale: float):
        body_lines: list[tuple[list[list[tuple[str, set[str]]]], int]] = []
        body_h = 0
        body_line_h = text_line_height(body_font)
        body_margin = max(0, round(paragraph_margin * spacing_scale))
        if body_text:
            body_lines, body_h, body_line_h = measure_wrapped_text_box(
                image,
                body_text,
                body_font,
                width,
                paragraph_margin,
                indent_chars,
                spacing_scale,
            )
        flavour_block = None
        flavour_h = 0
        if flavour_text:
            flavour_block = measure_flavour_block(
                image,
                flavour_text,
                flavour_font,
                width,
                paragraph_margin,
                indent_chars,
                spacing_scale,
                flavour_bottom_gap,
            )
            flavour_h = flavour_block["total_h"]
        return body_lines, body_h, body_line_h, body_margin, flavour_block, flavour_h

    body_lines, body_h, body_line_h, body_margin, flavour_block, flavour_h = measure(1.0)
    required_h = body_h + flavour_h
    spacing_scale = 1.0 if required_h <= 0 or required_h <= height else max(0.05, height / required_h)
    if spacing_scale < 1.0:
        body_lines, body_h, body_line_h, body_margin, flavour_block, flavour_h = measure(spacing_scale)
        for _ in range(3):
            scaled_required_h = body_h + flavour_h
            if scaled_required_h <= height or spacing_scale <= 0.05:
                break
            spacing_scale = max(0.05, spacing_scale * height / scaled_required_h * 0.99)
            body_lines, body_h, body_line_h, body_margin, flavour_block, flavour_h = measure(spacing_scale)

    if flavour_block is not None:
        flavour_top = y + height - flavour_h
        draw_flavour_block(image, flavour_block, x, flavour_top, width, flavour_font, color)
        body_area_h = max(1, flavour_top - y)
    else:
        body_area_h = height

    if body_text:
        body_y = y + max(0, round((body_area_h - body_h) / 2))
        draw_lines(image, body_lines, x, body_y, width, body_font, color, body_line_h, body_margin)
