"""Shared brand values, layout maths, and the two renderers.

Everything in this folder computes a layout once as a plain list of shapes, then hands that list to
one of two renderers: a raster one that needs Pillow, and a vector one that needs nothing but the
standard library. That is the whole reason the scripts still work on a machine without Pillow. It is
also why the SVG a page shows and the PNG a designer downloads are the same picture rather than two
drawings that happen to look similar.

Standard library plus Pillow only. No other dependency.
"""

import json
import os
import re
import struct
import zlib

# ----------------------------------------------------------------------------- brand
INK = "#00272C"
STEEL = "#245D63"
ORANGE = "#FF6B1C"
PAPER = "#F4F7F7"
MIST = "#EDF3F3"
ICE = "#A8B6B7"
MUTED = "#476467"
LINE = "#DEE3E4"
WHITE = "#FFFFFF"

# The four point spark from the brand system, drawn at 200x200 and scaled where it is used.
SPARK_PATH = "M100 10 L114 86 L190 100 L114 114 L100 190 L86 114 L10 100 L86 86 Z"

FONT_STACKS = {
    "display": "Archivo, 'Helvetica Neue', Helvetica, Arial, sans-serif",
    "sans": "'IBM Plex Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif",
    "mono": "'IBM Plex Mono', 'SF Mono', Menlo, Consolas, monospace",
}

# Candidate faces for the raster renderer, in order. The last resort is Pillow's own scalable
# default, so a machine with no system fonts still produces a readable frame.
FONT_FILES = {
    "display": [
        "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
        "/System/Library/Fonts/Supplemental/Helvetica.ttc",
        "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
        "C:/Windows/Fonts/arialbd.ttf",
    ],
    "sans": [
        "/System/Library/Fonts/Supplemental/Arial.ttf",
        "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
        "C:/Windows/Fonts/arial.ttf",
    ],
    "mono": [
        "/System/Library/Fonts/Supplemental/Courier New.ttf",
        "/System/Library/Fonts/Menlo.ttc",
        "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
        "C:/Windows/Fonts/cour.ttf",
    ],
}

# Average advance width as a fraction of the point size, per family. Used for line breaking so the
# raster and the vector renderer break lines in the same place, whatever face is actually installed.
AVG_ADVANCE = {"display": 0.545, "sans": 0.515, "mono": 0.600}


def have_pillow():
    try:
        import PIL.Image  # noqa: F401
        import PIL.ImageDraw  # noqa: F401
        return True
    except Exception:
        return False


# ----------------------------------------------------------------------------- text measuring
def text_width(text, size, family="sans", tracking=0.0):
    """An estimate, in pixels, deliberately used by both renderers so they agree on line breaks."""
    return len(text) * size * AVG_ADVANCE.get(family, 0.52) + max(0, len(text) - 1) * tracking * size


def wrap(text, size, family, max_width, tracking=0.0, max_lines=None):
    words = str(text).split()
    lines, cur = [], ""
    for w in words:
        trial = (cur + " " + w).strip()
        if cur and text_width(trial, size, family, tracking) > max_width:
            lines.append(cur)
            cur = w
        else:
            cur = trial
    if cur:
        lines.append(cur)
    if max_lines and len(lines) > max_lines:
        lines = lines[:max_lines]
        lines[-1] = lines[-1].rstrip(".,;: ") + "..."
    return lines


# ----------------------------------------------------------------------------- display list
def rect(x, y, w, h, fill, r=0):
    return {"kind": "rect", "x": x, "y": y, "w": w, "h": h, "fill": fill, "r": r}


def line(x1, y1, x2, y2, stroke, w=2):
    return {"kind": "line", "x1": x1, "y1": y1, "x2": x2, "y2": y2, "stroke": stroke, "w": w}


def text(x, y, s, size, family="sans", fill=INK, weight="normal", tracking=0.0, anchor="start"):
    """y is the text baseline, as it is in SVG."""
    return {"kind": "text", "x": x, "y": y, "text": str(s), "size": size, "family": family,
            "fill": fill, "weight": weight, "tracking": tracking, "anchor": anchor}


def spark(x, y, size, stroke, w=12):
    return {"kind": "spark", "x": x, "y": y, "size": size, "stroke": stroke, "w": w}


# ----------------------------------------------------------------------------- helpers
def hex_to_rgb(h):
    h = h.lstrip("#")
    if len(h) == 3:
        h = "".join(c * 2 for c in h)
    return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))


def esc(s):
    return (str(s).replace("&", "&amp;").replace("<", "&lt;")
            .replace(">", "&gt;").replace('"', "&quot;"))


def ensure_dir(p):
    d = os.path.dirname(os.path.abspath(p))
    if d:
        os.makedirs(d, exist_ok=True)


# ----------------------------------------------------------------------------- svg renderer
def render_svg(shapes, width, height, out_path, title=None):
    parts = ['<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="%d" height="%d" '
             'role="img">' % (width, height, width, height)]
    if title:
        parts.append("<title>%s</title>" % esc(title))
    for s in shapes:
        k = s["kind"]
        if k == "rect":
            parts.append('<rect x="%s" y="%s" width="%s" height="%s" rx="%s" fill="%s"/>'
                         % (s["x"], s["y"], s["w"], s["h"], s.get("r", 0), s["fill"]))
        elif k == "line":
            parts.append('<line x1="%s" y1="%s" x2="%s" y2="%s" stroke="%s" stroke-width="%s" '
                         'stroke-linecap="square"/>'
                         % (s["x1"], s["y1"], s["x2"], s["y2"], s["stroke"], s["w"]))
        elif k == "spark":
            sc = s["size"] / 200.0
            parts.append('<g transform="translate(%s,%s) scale(%s)"><path d="%s" fill="none" '
                         'stroke="%s" stroke-width="%s"/></g>'
                         % (s["x"], s["y"], round(sc, 5), SPARK_PATH, s["stroke"], s["w"]))
        elif k == "text":
            anchor = {"start": "start", "middle": "middle", "end": "end"}[s["anchor"]]
            extra = ""
            if s["tracking"]:
                extra += ' letter-spacing="%s"' % round(s["tracking"] * s["size"], 3)
            parts.append('<text x="%s" y="%s" font-family="%s" font-size="%s" font-weight="%s" '
                         'fill="%s" text-anchor="%s"%s>%s</text>'
                         % (s["x"], s["y"], esc(FONT_STACKS[s["family"]]), s["size"],
                            s["weight"], s["fill"], anchor, extra, esc(s["text"])))
        elif k == "svgchild":
            parts.append(s["markup"])
    parts.append("</svg>")
    ensure_dir(out_path)
    with open(out_path, "w", encoding="utf-8") as fh:
        fh.write("\n".join(parts))
    return out_path


# ----------------------------------------------------------------------------- png renderer
_font_cache = {}


def _font(family, size, weight):
    from PIL import ImageFont
    key = (family, int(size), weight)
    if key in _font_cache:
        return _font_cache[key]
    fam = family
    if family == "sans" and weight in ("bold", "600", "700", "800", "900"):
        fam = "display"
    f = None
    for path in FONT_FILES.get(fam, []):
        if os.path.exists(path):
            try:
                f = ImageFont.truetype(path, int(size))
                break
            except Exception:
                continue
    if f is None:
        try:
            f = ImageFont.load_default(size=int(size))
        except TypeError:
            f = ImageFont.load_default()
    _font_cache[key] = f
    return f


def _draw_tracked(draw, xy, s, font, fill, tracking_px, anchor):
    """Pillow has no letter spacing, so tracked text is drawn one glyph at a time."""
    widths = [draw.textlength(ch, font=font) for ch in s]
    total = sum(widths) + tracking_px * max(0, len(s) - 1)
    x, y = xy
    if anchor == "middle":
        x -= total / 2.0
    elif anchor == "end":
        x -= total
    for ch, w in zip(s, widths):
        draw.text((x, y), ch, font=font, fill=fill, anchor="ls")
        x += w + tracking_px
    return total


def render_png(shapes, width, height, out_path, background=WHITE):
    from PIL import Image, ImageDraw
    img = Image.new("RGB", (int(width), int(height)), hex_to_rgb(background))
    draw = ImageDraw.Draw(img)
    for s in shapes:
        k = s["kind"]
        if k == "rect":
            box = [s["x"], s["y"], s["x"] + s["w"], s["y"] + s["h"]]
            if s.get("r", 0):
                draw.rounded_rectangle(box, radius=s["r"], fill=hex_to_rgb(s["fill"]))
            else:
                draw.rectangle(box, fill=hex_to_rgb(s["fill"]))
        elif k == "line":
            draw.line([s["x1"], s["y1"], s["x2"], s["y2"]], fill=hex_to_rgb(s["stroke"]),
                      width=int(s["w"]))
        elif k == "spark":
            sc = s["size"] / 200.0
            pts = []
            for m in re.finditer(r"([ML])\s*([-\d.]+)\s+([-\d.]+)", SPARK_PATH):
                pts.append((s["x"] + float(m.group(2)) * sc, s["y"] + float(m.group(3)) * sc))
            draw.line(pts + [pts[0]], fill=hex_to_rgb(s["stroke"]), width=max(1, int(s["w"] * sc)),
                      joint="curve")
        elif k == "text":
            font = _font(s["family"], s["size"], s["weight"])
            fill = hex_to_rgb(s["fill"])
            if s["tracking"]:
                _draw_tracked(draw, (s["x"], s["y"]), s["text"], font, fill,
                              s["tracking"] * s["size"], s["anchor"])
            else:
                anchor = {"start": "ls", "middle": "ms", "end": "rs"}[s["anchor"]]
                draw.text((s["x"], s["y"]), s["text"], font=font, fill=fill, anchor=anchor)
        elif k == "paste":
            img.paste(s["image"], (int(s["x"]), int(s["y"])))
    return save_png(img, out_path)


def save_png(img, out_path, colors=128):
    """Flat brand artwork is a handful of colours plus antialiasing, so a palette PNG is roughly
    half the bytes of a truecolour one with no visible difference. Falls back if quantising fails."""
    ensure_dir(out_path)
    try:
        img.quantize(colors=colors).save(out_path, optimize=True)
    except Exception:
        img.save(out_path, optimize=True)
    return out_path


def render(shapes, width, height, out_base, formats, background=WHITE, title=None):
    """Write the same display list in every format asked for. Falls back to SVG without Pillow."""
    made = []
    want = list(formats)
    if "png" in want and not have_pillow():
        want = [f for f in want if f != "png"]
        if "svg" not in want:
            want.append("svg")
    if "svg" in want:
        made.append(render_svg(shapes, width, height, out_base + ".svg", title=title))
    if "png" in want:
        made.append(render_png(shapes, width, height, out_base + ".png", background=background))
    return made


# ----------------------------------------------------------------------------- image facts
def png_size(path):
    """Read a PNG header without Pillow. Returns (width, height) or None."""
    try:
        with open(path, "rb") as fh:
            head = fh.read(26)
        if head[:8] != b"\x89PNG\r\n\x1a\n":
            return None
        w, h = struct.unpack(">II", head[16:24])
        return (w, h)
    except Exception:
        return None


def svg_size(path):
    """Read width and height, or a viewBox, out of an SVG. Returns (width, height) or None."""
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as fh:
            head = fh.read(2000)
        w = re.search(r'\bwidth="(\d+(?:\.\d+)?)"', head)
        h = re.search(r'\bheight="(\d+(?:\.\d+)?)"', head)
        if w and h:
            return (int(float(w.group(1))), int(float(h.group(1))))
        vb = re.search(r'viewBox="[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)"', head)
        if vb:
            return (int(float(vb.group(1))), int(float(vb.group(2))))
    except Exception:
        pass
    return None


def image_size(path):
    ext = os.path.splitext(path)[1].lower()
    if ext == ".png":
        return png_size(path)
    if ext == ".svg":
        return svg_size(path)
    if have_pillow():
        try:
            from PIL import Image
            with Image.open(path) as im:
                return im.size
        except Exception:
            return None
    return None


def svg_inner(path):
    """The markup inside an <svg> element, plus its viewBox, so one frame can be nested in another."""
    with open(path, "r", encoding="utf-8") as fh:
        s = fh.read()
    m = re.search(r'viewBox="([-\d.]+)\s+([-\d.]+)\s+([\d.]+)\s+([\d.]+)"', s)
    vb = tuple(float(x) for x in m.groups()) if m else (0.0, 0.0, 1080.0, 1080.0)
    body = s[s.index(">", s.index("<svg")) + 1: s.rindex("</svg>")]
    body = re.sub(r"<title>.*?</title>", "", body, flags=re.S)
    return body.strip(), vb


def short_hash(path):
    h = zlib.crc32(b"")
    with open(path, "rb") as fh:
        while True:
            chunk = fh.read(65536)
            if not chunk:
                break
            h = zlib.crc32(chunk, h)
    return "%08x" % (h & 0xFFFFFFFF)


def load_json(path):
    with open(path, "r", encoding="utf-8") as fh:
        return json.load(fh)


def save_json(path, obj):
    ensure_dir(path)
    with open(path, "w", encoding="utf-8") as fh:
        json.dump(obj, fh, indent=1)
        fh.write("\n")
    return path
