"""
card_service.py — family sheet (10x15 cm) rendered as a PNG, the single source of truth for what gets printed.

The sheet carries no name at all (the schools may not give us any): the steps to follow online, the QR
and the access code, the class(es), and the photos of the child or siblings so that the parents recognise
their own sheet at a glance.

Same technique as hec-photo-app's qr_service (read its docstring for the reasoning), one deviation:
HEC keeps a React mirror of the card and a Pillow renderer and has to keep both in sync by hand. Here the
browser never re-draws the card: the preview, the print sheet and the ZIP all show THIS PNG, so frontend and
backend cannot drift (see DECISIONS.md).

What makes the output identical on every machine:
  * Every measurement is a CSS-pixel constant multiplied by S (=3), so the file is 1200 px wide.
  * Physical size is 100 x 150 mm (10x15 cm print); the DPI is written into the PNG so it prints at 1:1.
  * Fonts are bundled (backend/fonts): the card never depends on what the server has installed.
  * Letter-spacing is applied glyph by glyph and text is placed by BASELINE (anchor "ls"). Using "b" aligns the
    glyph's bottom edge instead and lifts every descender — HEC printed "PhotoGraPhe" because of it.
  * The QR uses the same parameters as /api/qr.svg (error level M, no Micro QR, 4-module quiet zone, navy on
    white): the photographer shoots these cards and the organizer must be able to read them.
"""
import io
import re
from datetime import date
from functools import lru_cache
from pathlib import Path
from typing import Optional

import segno
from PIL import Image, ImageDraw, ImageFont, ImageOps

S = 3                                  # CSS px -> output px
CARD_W_CSS, CARD_H_CSS = 400, 600      # 100 x 150 mm (10x15 print)
CARD_W, CARD_H = CARD_W_CSS * S, CARD_H_CSS * S
DPI = round(CARD_W * 25.4 / 100)       # 305

HEAD_H, GOLD_H, FOOT_H = 62 * S, 3 * S, 34 * S
BODY_Y = HEAD_H + GOLD_H
PAD_X = 24 * S
CONTENT_W = CARD_W - 2 * PAD_X
QR_BOX = 118 * S
QR_INNER = QR_BOX - 2 * 8 * S          # padding 8 CSS px
COL_GAP = 18 * S
TEXT_X = PAD_X + QR_BOX + COL_GAP
TEXT_W = CARD_W - PAD_X - TEXT_X
MAX_PHOTOS = 4

NAVY, NAVY_DEEP = (10, 31, 68), (7, 22, 51)
GOLD, GOLD_LIGHT = (201, 162, 39), (232, 212, 139)
BORDER, DIM = (231, 226, 214), (110, 117, 133)
CHIP_BG, FOOT_BG = (244, 234, 200), (251, 249, 243)
WHITE_80 = (206, 211, 222)             # rgba(255,255,255,.8) over NAVY

_FONTS = Path(__file__).resolve().parent.parent / "fonts"
_FILES = {"regular": "Inter-Regular.ttf", "semibold": "Inter-SemiBold.ttf",
          "bold": "Inter-Bold.ttf", "mono": "JetBrainsMono-Bold.ttf"}

# Printed text. Cards are handed to families, so they follow the family's language, not the operator's.
TEXTS = {
    "fr": {"school": "{school} · PHOTOS SCOLAIRES", "ready": "VOS PHOTOS SONT PRÊTES", "klass": "Classe : {c}",
           "steps": ["Scannez le QR code, ou entrez votre code sur le site.",
                     "Découvrez les photos de vos enfants et créez des photos « fun » avec l'IA.",
                     "Choisissez vos tirages ou fichiers numériques et payez en ligne."],
           "scan": "Scannez le QR code, ou", "visit": "rendez-vous sur",
           "rgpd": "Accès privé à vos photos uniquement · Photos supprimées automatiquement après {d} jours (RGPD)"},
    "en": {"school": "{school} · SCHOOL PHOTOS", "ready": "YOUR PHOTOS ARE READY", "klass": "Class: {c}",
           "steps": ["Scan the QR code, or enter your code on the website.",
                     "See your children's photos and create fun AI photos from them.",
                     "Choose your prints or digital files and pay online."],
           "scan": "Scan the QR code, or", "visit": "go to",
           "rgpd": "Private access to your photos only · Photos deleted automatically after {d} days (GDPR)"},
    "es": {"school": "{school} · FOTOS ESCOLARES", "ready": "TUS FOTOS ESTÁN LISTAS", "klass": "Clase: {c}",
           "steps": ["Escanea el código QR, o introduce tu código en la web.",
                     "Mira las fotos de tus hijos y crea fotos divertidas con IA.",
                     "Elige tus copias o archivos digitales y paga en línea."],
           "scan": "Escanea el código QR, o", "visit": "entra en",
           "rgpd": "Acceso privado solo a tus fotos · Fotos eliminadas automáticamente tras {d} días (RGPD)"},
}


@lru_cache(maxsize=64)
def _font(kind: str, css_px: float) -> ImageFont.FreeTypeFont:
    return ImageFont.truetype(str(_FONTS / _FILES[kind]), round(css_px * S))


def _w(font, text: str, spacing: int = 0) -> int:
    return int(font.getlength(text)) + spacing * max(len(text) - 1, 0)


def _text(draw, x: int, top: int, text: str, font, fill, spacing: int = 0) -> None:
    """Draw left-aligned text glyph by glyph; `top` is the top of the line box, placed by baseline."""
    base = top + font.getmetrics()[0]
    for ch in text:
        draw.text((x, base), ch, font=font, fill=fill, anchor="ls")
        x += int(font.getlength(ch)) + spacing


def _fit(font, text: str, max_w: int, spacing: int = 0) -> str:
    """Right-truncate with an ellipsis (never cut on the left: that is how a URL starts with 'ps://')."""
    if _w(font, text, spacing) <= max_w:
        return text
    while text and _w(font, text + "…", spacing) > max_w:
        text = text[:-1]
    return text.rstrip() + "…"


def _wrap(font, text: str, max_w: int, max_lines: int) -> list[str]:
    lines, cur = [], ""
    for word in text.split():
        trial = f"{cur} {word}".strip()
        if cur and _w(font, trial) > max_w:
            lines.append(cur)
            cur = word
        else:
            cur = trial
    if cur:
        lines.append(cur)
    if len(lines) > max_lines:
        lines = lines[:max_lines]
        lines[-1] = lines[-1] + " …"
    return [_fit(font, ln, max_w) for ln in lines]


def _wrap_items(font, items: list[str], max_w: int, max_lines: int, sep: str = " · ") -> list[str]:
    """Wrap whole items (one per student) so a separator never starts a line."""
    lines: list[str] = []
    cur = ""
    for it in items:
        trial = f"{cur}{sep}{it}" if cur else it
        if cur and _w(font, trial) > max_w:
            lines.append(cur)
            cur = it
        else:
            cur = trial
    if cur:
        lines.append(cur)
    if len(lines) > max_lines:
        lines = lines[:max_lines]
        lines[-1] += " …"
    return [_fit(font, ln, max_w) for ln in lines]


def _hgrad(draw, x0, y0, x1, y1, stops: list[tuple[float, tuple]]) -> None:
    n = x1 - x0
    for i in range(n):
        t = i / max(n - 1, 1)
        for (p0, c0), (p1, c1) in zip(stops, stops[1:]):
            if t <= p1:
                k = 0 if p1 == p0 else (t - p0) / (p1 - p0)
                draw.line([(x0 + i, y0), (x0 + i, y1 - 1)], fill=tuple(round(a + (b - a) * k) for a, b in zip(c0, c1)))
                break


def _qr_image(url: str, size_px: int) -> Image.Image:
    qr = segno.make(url, error="m", micro=False)
    n = len(qr.matrix) + 8                       # 4-module quiet zone on every side
    scale = max(size_px // n, 1)                 # integer module size: crisp, no resampling blur
    buf = io.BytesIO()
    qr.save(buf, kind="png", scale=scale, dark=NAVY, light="white", border=4)
    img = Image.open(buf).convert("RGB")
    canvas = Image.new("RGB", (size_px, size_px), "white")
    canvas.paste(img, ((size_px - img.width) // 2, (size_px - img.height) // 2))
    return canvas


def _dashed_rounded(d, box, radius: int, fill, width: int, dash: int = 6, gap: int = 4) -> None:
    """Dashed outline along the straight edges + solid corner arcs (CSS `1.5px dashed`)."""
    x0, y0, x1, y1 = box
    r = radius
    step = (dash + gap) * S // 2
    on = dash * S // 2
    for x in range(x0 + r, x1 - r, step):
        d.line([(x, y0), (min(x + on, x1 - r), y0)], fill=fill, width=width)
        d.line([(x, y1), (min(x + on, x1 - r), y1)], fill=fill, width=width)
    for y in range(y0 + r, y1 - r, step):
        d.line([(x0, y), (x0, min(y + on, y1 - r))], fill=fill, width=width)
        d.line([(x1, y), (x1, min(y + on, y1 - r))], fill=fill, width=width)
    d.arc([x0, y0, x0 + 2 * r, y0 + 2 * r], 180, 270, fill=fill, width=width)
    d.arc([x1 - 2 * r, y0, x1, y0 + 2 * r], 270, 360, fill=fill, width=width)
    d.arc([x0, y1 - 2 * r, x0 + 2 * r, y1], 90, 180, fill=fill, width=width)
    d.arc([x1 - 2 * r, y1 - 2 * r, x1, y1], 0, 90, fill=fill, width=width)


def _photo_tile(path: Path, w: int, h: int) -> Optional[Image.Image]:
    """A photo cropped to w x h (centred on the upper part: these are portraits) with rounded corners."""
    try:
        with Image.open(path) as im:
            im = ImageOps.exif_transpose(im).convert("RGB")
            im.thumbnail((w * 2, h * 2))                      # decoding a 12 Mpx original is the slow part
            tile = ImageOps.fit(im, (w, h), method=Image.LANCZOS, centering=(0.5, 0.28))
    except Exception:
        return None
    mask = Image.new("L", (w, h), 0)
    ImageDraw.Draw(mask).rounded_rectangle([0, 0, w - 1, h - 1], radius=8 * S, fill=255)
    tile.putalpha(mask)
    return tile


def render_card(*, code: str, classes: list[str], school_name: str, portal_url: str,
                rgpd_days: str, lang: str = "fr", year: Optional[int] = None,
                brand_color: str = "", photos: Optional[list[Path]] = None) -> bytes:
    """One family sheet as PNG bytes. `portal_url` is the portal base (https://photos.example.com);
    `photos` are original files of the child/siblings (the first MAX_PHOTOS are used)."""
    tx = TEXTS.get(lang) or TEXTS["fr"]
    year = year or date.today().year
    base = portal_url.rstrip("/")
    link = f"{base}/?code={code}"
    host = re.sub(r"^https?://", "", base)

    img = Image.new("RGB", (CARD_W, CARD_H), "white")
    d = ImageDraw.Draw(img)

    # Header: the school's accent colour (validated dark in school_service), navy by default.
    if re.match(r"^#[0-9a-fA-F]{6}$", brand_color or ""):
        accent = tuple(int(brand_color[i:i + 2], 16) for i in (1, 3, 5))
        deep = tuple(int(c * 0.7) for c in accent)
        _hgrad(d, 0, 0, CARD_W, HEAD_H, [(0, deep), (0.7, accent), (1, accent)])
    else:
        _hgrad(d, 0, 0, CARD_W, HEAD_H, [(0, NAVY_DEEP), (0.7, NAVY), (1, NAVY)])
    f_logo, f_school = _font("bold", 17), _font("regular", 10.5)
    logo_sp = 3 * S
    _text(d, PAD_X, 16 * S, "MEE", f_logo, (255, 255, 255), logo_sp)
    _text(d, PAD_X + _w(f_logo, "MEE", logo_sp) + logo_sp, 16 * S, "CONIC", f_logo, GOLD_LIGHT, logo_sp)
    school_sp = round(1.5 * S)
    school_line = _fit(f_school, tx["school"].format(school=school_name.upper()) + f" {year}",
                       CARD_W - 2 * PAD_X, school_sp)
    _text(d, PAD_X, 38 * S, school_line, f_school, WHITE_80, school_sp)
    _hgrad(d, 0, HEAD_H, CARD_W, BODY_Y, [(0, GOLD), (0.5, GOLD_LIGHT), (1, GOLD)])

    # Title + class(es)
    f_ready, f_class = _font("bold", 10.5), _font("semibold", 14)
    y = BODY_Y + 16 * S
    _text(d, PAD_X, y, tx["ready"], f_ready, GOLD, round(2.5 * S))
    y += 18 * S
    cls_text = " · ".join(dict.fromkeys(c for c in classes if c))
    if cls_text:
        _text(d, PAD_X, y, _fit(f_class, tx["klass"].format(c=cls_text), CONTENT_W), f_class, NAVY)
    y += 28 * S

    # Photos of the child / siblings, so the parents recognise their sheet
    paths = list(photos or [])[:MAX_PHOTOS]
    tiles = []
    if paths:
        gap = 8 * S
        n = len(paths)
        h = (180 if n == 1 else 150 if n == 2 else 120) * S
        w = (CONTENT_W - gap * (n - 1)) // n if n > 1 else 140 * S
        row_w = w * n + gap * (n - 1)
        x = PAD_X + (CONTENT_W - row_w) // 2
        for path in paths:
            tile = _photo_tile(Path(path), w, h)
            if tile is not None:
                tiles.append((tile, x))
            x += w + gap
        if tiles:
            for tile, tx_ in tiles:
                img.paste(tile, (tx_, y), tile)
            y += h + 14 * S

    # The three steps
    f_step, f_num = _font("regular", 11.5), _font("bold", 11)
    for i, step in enumerate(tx["steps"], 1):
        cx, cy, r = PAD_X + 9 * S, y + 9 * S, 9 * S
        d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=NAVY)
        num = str(i)
        asc, _ = f_num.getmetrics()
        d.text((cx, cy), num, font=f_num, fill=(255, 255, 255), anchor="mm")
        lines = _wrap(f_step, step, CONTENT_W - 28 * S, 2)
        ly = y
        for ln in lines:
            _text(d, PAD_X + 26 * S, ly, ln, f_step, (60, 66, 82))
            ly += 15 * S
        y += max(len(lines) * 15 * S, 20 * S) + 8 * S

    # QR + access code, pinned above the footer
    foot_y = CARD_H - FOOT_H
    box_y = foot_y - QR_BOX - 12 * S
    d.rounded_rectangle([PAD_X, box_y, PAD_X + QR_BOX - 1, box_y + QR_BOX - 1], radius=12 * S,
                        fill="white", outline=BORDER, width=S)
    img.paste(_qr_image(link, QR_INNER), (PAD_X + 8 * S, box_y + 8 * S))

    f_small, f_host, f_code = _font("regular", 11), _font("semibold", 11), _font("mono", 16)
    code_sp, chip_h, line_small = round(2.5 * S), 34 * S, 15 * S
    block_h = 2 * line_small + 8 * S + chip_h
    ty = box_y + (QR_BOX - block_h) // 2
    _text(d, TEXT_X, ty, tx["scan"], f_small, DIM)
    ty += line_small
    visit = tx["visit"] + " "
    _text(d, TEXT_X, ty, visit, f_small, DIM)
    _text(d, TEXT_X + _w(f_small, visit), ty, _fit(f_host, host, TEXT_W - _w(f_small, visit)), f_host, NAVY)
    ty += line_small + 8 * S
    chip_w = min(_w(f_code, code, code_sp) + 28 * S, TEXT_W)
    chip = [TEXT_X, ty, TEXT_X + chip_w, ty + chip_h - 1]
    d.rounded_rectangle(chip, radius=10 * S, fill=CHIP_BG)
    _dashed_rounded(d, chip, 10 * S, GOLD, round(1.5 * S))
    asc, desc = f_code.getmetrics()
    _text(d, TEXT_X + 14 * S, ty + (chip_h - (asc + desc)) // 2, code, f_code, NAVY, code_sp)

    # Footer
    d.rectangle([0, foot_y, CARD_W, CARD_H], fill=FOOT_BG)
    d.line([(0, foot_y), (CARD_W, foot_y)], fill=BORDER, width=S)
    f_rgpd = _font("regular", 9.5)
    rg = _wrap(f_rgpd, tx["rgpd"].format(d=rgpd_days), CARD_W - 2 * PAD_X, 2)
    ry = foot_y + (FOOT_H - len(rg) * 13 * S) // 2
    for ln in rg:
        _text(d, PAD_X, ry, ln, f_rgpd, DIM)
        ry += 13 * S

    # Outer border last so nothing paints over it
    d.rounded_rectangle([0, 0, CARD_W - 1, CARD_H - 1], radius=18 * S, outline=BORDER, width=S)

    buf = io.BytesIO()
    img.save(buf, format="PNG", dpi=(DPI, DPI), compress_level=1)  # level 1: ~5x faster, fine for bulk
    return buf.getvalue()
