#!/usr/bin/env python3
"""Generador de fuentes bitmap para Projecte 2026.

Convierte un archivo .ttf (o un GIF existente) en un GIF indexado + fichero .fnt
compatibles con el sistema de texto del juego.

Dependencias: pip install Pillow

Uso (desde TTF):
    python3 font_gen.py --ttf myfont.ttf --size 8 --output myfont
    python3 font_gen.py --ttf myfont.ttf --size 8 --output myfont --dir data/font --box-width 8

Uso (desde GIF existente con cuadrícula):
    python3 font_gen.py --gif myfont.gif --output myfont --columns 16 --cell-spacing 1

Notas:
    - Para fuentes bitmap (pixel fonts) en TTF, usa el tamaño exacto del bitmap strike.
    - Los glifos se almacenan como índice de paleta 1 (blanco) sobre fondo transparente (índice 0).
    - Esto es compatible con SurfaceSprite::render(1, color) del motor del juego.
    - Los caracteres no incluidos en la fuente aparecerán como celdas vacías en el GIF.
    - El modo --gif acepta un GIF cuya cuadrícula siga el orden de ALL_CHARS.
      Celdas vacías (todos los píxeles = índice 0) se marcan como no soportadas.
"""

import argparse
import os
import shutil
import sys
from math import ceil

try:
    from PIL import Image, ImageDraw, ImageFont
except ImportError:
    print("Error: Pillow no está instalado. Ejecuta: pip install Pillow", file=sys.stderr)
    sys.exit(1)

# Conjunto de caracteres en el mismo orden que los ficheros .fnt del juego.
# ASCII 32-126 primero, luego extensiones para castellano, catalán y valenciano.
_ASCII_CHARS    = [chr(i) for i in range(32, 127)]        # 95 chars: espacio … ~
_EXTENDED_CHARS = list("ÀÁÈÉÌÍÒÓÙÚÑÇàáèéìíòóùúñç¡¿«»·")   # 29 chars ES/CA/VA
ALL_CHARS = _ASCII_CHARS + _EXTENDED_CHARS                # 124 total

# Caracteres de fallback para TTFs sin soporte de acentos/especiales.
# Si el TTF no tiene el char, se dibuja el fallback en su celda del bitmap.
# El .fnt sigue registrando el codepoint original → texto localizado funciona.
CHAR_FALLBACKS: dict[str, str] = {
    "À": "A", "Á": "A", "È": "E", "É": "E",
    "Ì": "I", "Í": "I", "Ò": "O", "Ó": "O",
    "Ù": "U", "Ú": "U", "Ñ": "N", "Ç": "C",
    "à": "a", "á": "a", "è": "e", "é": "e",
    "ì": "i", "í": "i", "ò": "o", "ó": "o",
    "ù": "u", "ú": "u", "ñ": "n", "ç": "c",
    "¡": "!", "¿": "?", "«": "<", "»": ">", "·": ".",
}


def _write_fnt(
    output_fnt: str,
    output_name: str,
    source_name: str,
    box_width: int,
    box_height: int,
    columns: int,
    cell_spacing: int,
    row_spacing: int,
    chars: list[str],
    char_widths: dict[str, int],
) -> None:
    """Escribe el fichero .fnt."""
    with open(output_fnt, "w", encoding="utf-8") as f:
        f.write(f"# Font: {output_name} — generado desde {source_name}\n")
        f.write(f"# Generado con tools/font_gen/font_gen.py\n\n")
        f.write(f"box_width {box_width}\n")
        f.write(f"box_height {box_height}\n")
        f.write(f"columns {columns}\n")
        if cell_spacing:
            f.write(f"cell_spacing {cell_spacing}\n")
        if row_spacing and row_spacing != cell_spacing:
            f.write(f"row_spacing {row_spacing}\n")
        f.write("\n# codepoint_decimal  ancho_visual\n")
        for ch in chars:
            cp   = ord(ch)
            w    = char_widths[ch]
            name = ch if ch.isprintable() and not ch.isspace() else f"U+{cp:04X}"
            f.write(f"{cp} {w}  # {name}\n")


def render_gif_font(
    gif_path: str,
    output_name: str,
    output_dir: str,
    columns: int,
    box_width: int,
    box_height: int,
    cell_spacing: int,
    row_spacing: int,
) -> None:
    """Genera el .fnt a partir de un GIF de fuente existente con cuadrícula.

    Fórmula de posición de cada celda (col, row):
        x0 = col * (box_width  + cell_spacing) + cell_spacing
        y0 = row * (box_height + row_spacing)  + cell_spacing
    """

    if not os.path.isfile(gif_path):
        print(f"Error: No se encuentra el archivo GIF: {gif_path}", file=sys.stderr)
        sys.exit(1)

    os.makedirs(output_dir, exist_ok=True)
    output_gif = os.path.join(output_dir, f"{output_name}.gif")
    output_fnt = os.path.join(output_dir, f"{output_name}.fnt")

    img = Image.open(gif_path)
    if img.mode != "P":
        img = img.convert("P")
    img_w, img_h = img.size
    pixels = img.load()

    num_rows = ceil(len(ALL_CHARS) / columns)
    stride_x = box_width  + cell_spacing
    stride_y = box_height + row_spacing

    print(f"GIF fuente: {os.path.basename(gif_path)}  ({img_w}×{img_h} px)")
    print(f"Caja      : {box_width}×{box_height} px  |  {columns} cols, {num_rows} filas  |  spacing x={cell_spacing} y={row_spacing}")

    char_widths: dict[str, int] = {}
    supported_chars: list[str] = []
    skipped: list[str] = []

    for i, ch in enumerate(ALL_CHARS):
        col = i % columns
        row = i // columns
        x0  = col * stride_x + cell_spacing
        y0  = row * stride_y + cell_spacing

        # Comprobar límites (el GIF puede tener más o menos filas que ALL_CHARS)
        if y0 + box_height > img_h or x0 + box_width > img_w:
            skipped.append(ch)
            continue

        # Celda vacía (todos los píxeles = índice 0)
        if all(pixels[x0 + px, y0 + py] == 0 for py in range(box_height) for px in range(box_width)):
            if ch.isspace():
                # El espacio no tiene píxeles visibles: asignar ancho por defecto
                char_widths[ch] = max(1, box_width // 2)
                supported_chars.append(ch)
            else:
                skipped.append(ch)
            continue

        # Medir ancho visual: última columna con algún píxel no-fondo
        pixel_width = 0
        for px in range(box_width - 1, -1, -1):
            if any(pixels[x0 + px, y0 + py] != 0 for py in range(box_height)):
                pixel_width = px + 1
                break

        char_widths[ch] = max(1, pixel_width)
        supported_chars.append(ch)

    if skipped:
        names = "".join(c if c.isprintable() and not c.isspace() else f"[U+{ord(c):04X}]" for c in skipped)
        print(f"Omitidos  : {len(skipped)} chars vacíos/fuera de rango: {names}")
    print(f"Soportados: {len(supported_chars)} caracteres")

    # Copiar el GIF al directorio de salida
    shutil.copy2(gif_path, output_gif)
    print(f"GIF       : {output_gif}")

    _write_fnt(output_fnt, output_name, os.path.basename(gif_path),
               box_width, box_height, columns, cell_spacing, row_spacing, supported_chars, char_widths)
    print(f"FNT       : {output_fnt}")


def render_font(
    ttf_path: str,
    size: int,
    output_name: str,
    output_dir: str,
    columns: int,
    box_width_override: int | None,
    box_height_override: int | None,
) -> None:
    """Genera el GIF indexado y el .fnt a partir de un archivo .ttf."""

    if not os.path.isfile(ttf_path):
        print(f"Error: No se encuentra el archivo TTF: {ttf_path}", file=sys.stderr)
        sys.exit(1)

    os.makedirs(output_dir, exist_ok=True)
    output_gif = os.path.join(output_dir, f"{output_name}.gif")
    output_fnt = os.path.join(output_dir, f"{output_name}.fnt")

    # --- Cargar fuente ---
    try:
        font = ImageFont.truetype(ttf_path, size)
    except OSError as e:
        print(f"Error al cargar la fuente: {e}", file=sys.stderr)
        sys.exit(1)

    # --- Calcular dimensiones de la caja ---
    # box_height = línea completa: ascent (sobre la línea base) + descent (bajo ella)
    ascent, descent = font.getmetrics()
    box_height = box_height_override if box_height_override is not None else (ascent + abs(descent))

    # Calcular y_offset antes de la clasificación (necesario para detectar .notdef)
    cap_tops = [font.getbbox(ch)[1] for ch in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" if font.getbbox(ch)]
    y_offset = -round(sum(cap_tops) / len(cap_tops)) if cap_tops else 0

    # Detectar el glifo .notdef: algunos TTF devuelven un glifo sustituto (con
    # advance > 0 y bbox válido) para chars que no tienen en su cmap. Esto provoca
    # falsos positivos en el test getlength >= 1. Se renderiza U+FFFE (garantizado
    # ausente en cualquier fuente de uso normal) y se guardan sus píxeles como
    # referencia. Cualquier char con píxeles idénticos se considera no soportado.
    _tmp_w = box_width_override or 32
    _nd_bbox = font.getbbox(chr(0xFFFE))
    if _nd_bbox:
        _nd_img = Image.new("RGBA", (_tmp_w, box_height), (0, 0, 0, 0))
        ImageDraw.Draw(_nd_img).text((-_nd_bbox[0], y_offset), chr(0xFFFE),
                                     font=font, fill=(255, 255, 255, 255))
        _notdef_bytes = _nd_img.tobytes()
    else:
        _notdef_bytes = None

    def _is_notdef(ch: str) -> bool:
        """True si el char renderiza el glifo .notdef en lugar de un glifo real."""
        if _notdef_bytes is None:
            return False
        bbox = font.getbbox(ch)
        if not bbox:
            return True
        img = Image.new("RGBA", (_tmp_w, box_height), (0, 0, 0, 0))
        ImageDraw.Draw(img).text((-bbox[0], y_offset), ch, font=font, fill=(255, 255, 255, 255))
        return img.tobytes() == _notdef_bytes

    # Clasificar chars: soportados nativamente, con fallback, o sin soporte.
    # Un char tiene soporte nativo si su advance >= 1 Y no renderiza .notdef.
    # Si no, se busca en CHAR_FALLBACKS. Sin soporte y sin fallback, se omite.
    chars_to_render: list[str] = []
    char_render_as:  dict[str, str] = {}   # char → qué char dibujar realmente
    truly_skipped:   list[str] = []

    for ch in ALL_CHARS:
        if ch.isspace():
            chars_to_render.append(ch)
            char_render_as[ch] = ch
        elif font.getlength(ch) >= 1.0 and not _is_notdef(ch):
            chars_to_render.append(ch)
            char_render_as[ch] = ch
        elif ch in CHAR_FALLBACKS and font.getlength(CHAR_FALLBACKS[ch]) >= 1.0:
            chars_to_render.append(ch)
            char_render_as[ch] = CHAR_FALLBACKS[ch]
        else:
            truly_skipped.append(ch)

    if truly_skipped:
        print(f"Omitidos : {len(truly_skipped)} chars sin soporte ni fallback: {''.join(truly_skipped)}")
    fallback_used = [ch for ch, r in char_render_as.items() if r != ch]
    if fallback_used:
        print(f"Fallback : {len(fallback_used)} chars con fallback: " +
              " ".join(f"{ch}→{char_render_as[ch]}" for ch in fallback_used))

    # Medir advance width tipográfico: solo se usa para calcular box_width del canvas
    # cuando el usuario no lo especifica. El ancho real del .fnt se mide desde píxeles.
    char_widths: dict[str, int] = {}
    for ch in chars_to_render:
        render_ch = char_render_as[ch]
        char_widths[ch] = max(1, int(font.getlength(render_ch)))

    # box_width: anchura de cada celda en el bitmap.
    # Si el usuario la especifica, se usa tal cual. Si no, se calcula como el
    # advance máximo (sin padding extra, ya que getlength incluye el espaciado).
    if box_width_override is not None:
        box_width = box_width_override
    else:
        box_width = max(char_widths.values())

    # --- Calcular dimensiones del bitmap completo ---
    rows       = ceil(len(chars_to_render) / columns)
    img_width  = columns * box_width
    img_height = rows    * box_height

    print(f"Fuente   : {os.path.basename(ttf_path)}, size={size}")
    print(f"Caja     : {box_width}×{box_height} px  |  {columns} columnas, {rows} filas")
    print(f"Bitmap   : {img_width}×{img_height} px  |  {len(chars_to_render)} caracteres")

    # --- Buffer de píxeles: 0 = fondo transparente, 1 = glifo ---
    pixels = bytearray(img_width * img_height)

    # --- Renderizar cada carácter ---
    for i, ch in enumerate(chars_to_render):
        col    = i % columns
        row    = i // columns
        cell_x = col * box_width
        cell_y = row * box_height

        draw_ch = char_render_as[ch]   # char que realmente se dibuja (puede ser fallback)
        bbox = font.getbbox(draw_ch)
        if not bbox:
            # Sin glifos visibles (ej. espacio): celda vacía, correcto.
            continue

        # Renderizar a imagen RGBA con fondo transparente, texto blanco.
        #
        # POSICIONAMIENTO VERTICAL: se usa y=0 para respetar la posición del
        # carácter relativa a la línea base del em-box. draw.text((x, 0), ch)
        # coloca la parte superior del em-box en y=0, de modo que la línea base
        # queda en y=ascent y los signos de puntuación (que están cerca de la
        # línea base) aparecen en la parte inferior de la celda, como es correcto.
        #
        # POSICIONAMIENTO HORIZONTAL: -bbox[0] alinea el borde izquierdo del
        # glifo al inicio de la celda, compensando el bearing izquierdo.
        char_img = Image.new("RGBA", (box_width, box_height), (0, 0, 0, 0))
        draw = ImageDraw.Draw(char_img)
        draw.text((-bbox[0], y_offset), draw_ch, font=font, fill=(255, 255, 255, 255))

        # Umbralizar alpha y volcar al buffer de índices
        char_bytes = char_img.tobytes()
        for py in range(box_height):
            for px in range(box_width):
                src = (py * box_width + px) * 4
                if char_bytes[src + 3] > 128:
                    pixels[(cell_y + py) * img_width + (cell_x + px)] = 1

        # Medir ancho visual real: última columna con algún píxel opaco.
        # Reemplaza el advance tipográfico de getlength() que incluye side-bearings.
        for px in range(box_width - 1, -1, -1):
            if any(char_bytes[(py * box_width + px) * 4 + 3] > 128 for py in range(box_height)):
                char_widths[ch] = px + 1
                break

    # --- Crear imagen P (paleta indexada 8 bits) ---
    img = Image.frombytes("P", (img_width, img_height), bytes(pixels))

    # Paleta mínima: índice 0 = negro (transparente), índice 1 = blanco (glifo)
    palette = [0] * 768
    palette[3] = palette[4] = palette[5] = 255   # índice 1 → blanco
    img.putpalette(palette)

    # Guardar GIF: índice 0 = fondo, índice 1 = glifo.
    # Pillow sin transparency escribe GIF87a. Luego magick reduce la paleta a
    # 2 colores (lzw_min=2) para compatibilidad exacta con el parser gif.cpp.
    img.save(output_gif, optimize=False)
    try:
        import subprocess
        subprocess.run(
            ["magick", output_gif, "-colors", "2", f"GIF87:{output_gif}"],
            check=True, capture_output=True,
        )
    except (subprocess.CalledProcessError, FileNotFoundError):
        pass  # si magick no está disponible se queda como GIF87a de 256 colores
    print(f"GIF      : {output_gif}")

    _write_fnt(output_fnt, output_name, f"{os.path.basename(ttf_path)} size {size}",
               box_width, box_height, columns, 0, 0, chars_to_render, char_widths)
    print(f"FNT      : {output_fnt}")


def main() -> None:
    # Directorio del proyecto: dos niveles arriba de este script (tools/font_gen/)
    script_dir  = os.path.dirname(os.path.abspath(__file__))
    project_dir = os.path.dirname(os.path.dirname(script_dir))
    default_dir = os.path.join(project_dir, "data", "font")

    parser = argparse.ArgumentParser(
        description="Genera fuentes bitmap (.gif + .fnt) desde un .ttf o un GIF existente.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Ejemplos (desde TTF):
  python3 font_gen.py --ttf myfont.ttf --size 8 --output myfont
  python3 font_gen.py --ttf myfont.ttf --size 8 --output myfont --box-width 8
  python3 font_gen.py --ttf myfont.ttf --size 16 --output myfont --dir data/font

Ejemplos (desde GIF con cuadrícula):
  python3 font_gen.py --gif myfont.gif --output myfont --columns 16 --box-width 10 --box-height 7 --cell-spacing 1
  python3 font_gen.py --gif myfont.gif --output myfont --columns 16 --box-width 10 --box-height 7 --cell-spacing 1 --row-spacing 4

Notas:
  - Para pixel fonts (.ttf bitmap), usa el tamaño exacto del bitmap strike.
  - El GIF resultante usa índice 1 = glifo, índice 0 = transparente.
  - Se procesan 124 caracteres: ASCII 32-126 + extensiones ES/CA/VA.
  - En modo --gif, --box-width y --box-height son obligatorios.
  - Las celdas completamente vacías (índice 0) se marcan como no soportadas.
  - cell_spacing = spacing horizontal entre columnas (y borde izquierdo/superior).
  - row_spacing = spacing vertical entre filas (si difiere de cell_spacing).
        """,
    )

    # Fuente: TTF o GIF (mutuamente excluyentes)
    source_group = parser.add_mutually_exclusive_group(required=True)
    source_group.add_argument("--ttf", help="Ruta al archivo .ttf")
    source_group.add_argument("--gif", help="Ruta a un GIF de fuente existente con cuadrícula")

    parser.add_argument("--size",         type=int,            help="Tamaño en píxeles (solo con --ttf)")
    parser.add_argument("--output",       required=True,       help="Nombre base de salida (sin extensión)")
    parser.add_argument("--dir",          default=default_dir, help="Directorio de salida (default: data/font/)")
    parser.add_argument("--columns",      default=15, type=int, help="Columnas en el bitmap (default: 15)")
    parser.add_argument("--box-width",    default=None, type=int, help="Anchura fija de celda en px (default: auto)")
    parser.add_argument("--box-height",   default=None, type=int, help="Altura fija de celda en px (default: auto)")
    parser.add_argument("--cell-spacing", default=0,    type=int, help="Píxeles de separación entre columnas, y borde (default: 0)")
    parser.add_argument("--row-spacing",  default=None, type=int, help="Píxeles de separación entre filas (default: igual a --cell-spacing)")

    args = parser.parse_args()

    if args.gif:
        if args.box_width is None or args.box_height is None:
            parser.error("--box-width y --box-height son obligatorios con --gif")
        row_sp = args.row_spacing if args.row_spacing is not None else args.cell_spacing
        render_gif_font(
            gif_path     = args.gif,
            output_name  = args.output,
            output_dir   = args.dir,
            columns      = args.columns,
            box_width    = args.box_width,
            box_height   = args.box_height,
            cell_spacing = args.cell_spacing,
            row_spacing  = row_sp,
        )
    else:
        if args.size is None:
            parser.error("--size es obligatorio cuando se usa --ttf")
        render_font(
            ttf_path           = args.ttf,
            size               = args.size,
            output_name        = args.output,
            output_dir         = args.dir,
            columns            = args.columns,
            box_width_override  = args.box_width,
            box_height_override = args.box_height,
        )


if __name__ == "__main__":
    main()
