"""Minimal PDF writer. Standard library only.

Enough of the PDF spec to emit multi-page vector line art with Helvetica
labels, which is all a sewing pattern needs. No external dependency, so the
generator runs on a bare Python install.

Coordinates handed to this module are in PDF points, origin bottom-left,
72 points per inch.
"""

from __future__ import annotations

LETTER = (612.0, 792.0)  # 8.5 x 11 inches, in points


def _esc(text: str) -> str:
    return text.replace("\\", r"\\").replace("(", r"\(").replace(")", r"\)")


# Helvetica advance widths, in 1/1000 em, for the printable ASCII range.
# Only used to center and right-align text, so approximate is acceptable.
_WIDTHS = {
    " ": 278, "!": 278, '"': 355, "#": 556, "$": 556, "%": 889, "&": 667,
    "'": 191, "(": 333, ")": 333, "*": 389, "+": 584, ",": 278, "-": 333,
    ".": 278, "/": 278, ":": 278, ";": 278, "<": 584, "=": 584, ">": 584,
    "?": 556, "@": 1015, "[": 278, "\\": 278, "]": 278, "^": 469, "_": 556,
    "`": 333, "{": 334, "|": 260, "}": 334, "~": 584,
}
for _c in "0123456789":
    _WIDTHS[_c] = 556
for _c in "abcdefghijklmnopqrstuvwxyz":
    _WIDTHS[_c] = {"i": 222, "j": 222, "l": 222, "f": 278, "t": 278,
                   "m": 833, "w": 722, "r": 333, "k": 500}.get(_c, 556)
for _c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
    _WIDTHS[_c] = {"I": 278, "J": 500, "L": 556, "M": 833, "W": 944,
                   "A": 667, "B": 667, "C": 722, "D": 722, "E": 667,
                   "F": 611, "G": 778, "H": 722, "K": 722, "N": 722,
                   "O": 778, "P": 667, "Q": 778, "R": 722, "S": 667,
                   "T": 611, "U": 722, "V": 667, "X": 667, "Y": 667,
                   "Z": 611}.get(_c, 667)


def text_width(text: str, size: float, bold: bool = False) -> float:
    """Approximate rendered width of `text` in points."""
    total = sum(_WIDTHS.get(ch, 556) for ch in text)
    if bold:
        total *= 1.06
    return total * size / 1000.0


class Page:
    """One page's content stream, built up as PDF operators."""

    def __init__(self, width: float, height: float) -> None:
        self.width = width
        self.height = height
        self.ops: list[str] = []
        self._stroke = None
        self._width = None
        self._dash = None

    # --- state ---------------------------------------------------------

    def stroke(self, r: float, g: float, b: float) -> None:
        key = (round(r, 3), round(g, 3), round(b, 3))
        if key != self._stroke:
            self.ops.append(f"{key[0]} {key[1]} {key[2]} RG")
            self._stroke = key

    def fill(self, r: float, g: float, b: float) -> None:
        self.ops.append(f"{r:.3f} {g:.3f} {b:.3f} rg")

    def linewidth(self, w: float) -> None:
        if w != self._width:
            self.ops.append(f"{w:.2f} w")
            self._width = w

    def dash(self, pattern: str = "") -> None:
        if pattern != self._dash:
            self.ops.append(f"[{pattern}] 0 d")
            self._dash = pattern

    # --- geometry ------------------------------------------------------

    def line(self, x1: float, y1: float, x2: float, y2: float) -> None:
        self.ops.append(f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l S")

    def polyline(self, points, close: bool = False) -> None:
        if not points:
            return
        x, y = points[0]
        seg = [f"{x:.2f} {y:.2f} m"]
        for x, y in points[1:]:
            seg.append(f"{x:.2f} {y:.2f} l")
        seg.append("h S" if close else "S")
        self.ops.append(" ".join(seg))

    def rect(self, x: float, y: float, w: float, h: float) -> None:
        self.ops.append(f"{x:.2f} {y:.2f} {w:.2f} {h:.2f} re S")

    def filled_rect(self, x: float, y: float, w: float, h: float,
                    rgb=(0, 0, 0)) -> None:
        self.ops.append(
            f"q {rgb[0]:.3f} {rgb[1]:.3f} {rgb[2]:.3f} rg "
            f"{x:.2f} {y:.2f} {w:.2f} {h:.2f} re f Q"
        )

    def circle(self, cx: float, cy: float, r: float) -> None:
        k = 0.5523 * r
        self.ops.append(
            f"{cx - r:.2f} {cy:.2f} m "
            f"{cx - r:.2f} {cy + k:.2f} {cx - k:.2f} {cy + r:.2f} {cx:.2f} {cy + r:.2f} c "
            f"{cx + k:.2f} {cy + r:.2f} {cx + r:.2f} {cy + k:.2f} {cx + r:.2f} {cy:.2f} c "
            f"{cx + r:.2f} {cy - k:.2f} {cx + k:.2f} {cy - r:.2f} {cx:.2f} {cy - r:.2f} c "
            f"{cx - k:.2f} {cy - r:.2f} {cx - r:.2f} {cy - k:.2f} {cx - r:.2f} {cy:.2f} c S"
        )

    # --- clipping ------------------------------------------------------

    def clip(self, x: float, y: float, w: float, h: float) -> None:
        """Restrict following drawing to a rectangle, until end_clip()."""
        self.ops.append(f"q {x:.2f} {y:.2f} {w:.2f} {h:.2f} re W n")
        self._reset_state()

    def end_clip(self) -> None:
        self.ops.append("Q")
        self._reset_state()

    def _reset_state(self) -> None:
        # q/Q restores the graphics state, so the cached values no longer
        # describe the stream. Forget them or later set-ops get skipped.
        self._stroke = None
        self._width = None
        self._dash = None

    # --- text ----------------------------------------------------------

    def text(self, x: float, y: float, s: str, size: float = 9.0,
             bold: bool = False, rgb=(0, 0, 0), align: str = "left",
             rotate: bool = False) -> None:
        font = "/F2" if bold else "/F1"
        w = text_width(s, size, bold)
        if align == "center":
            shift = -w / 2.0
        elif align == "right":
            shift = -w
        else:
            shift = 0.0
        if rotate:  # 90 degrees counter-clockwise, reading bottom to top
            self.ops.append(
                f"q {rgb[0]:.3f} {rgb[1]:.3f} {rgb[2]:.3f} rg BT {font} {size} Tf "
                f"0 1 -1 0 {x:.2f} {y + shift:.2f} Tm ({_esc(s)}) Tj ET Q"
            )
        else:
            self.ops.append(
                f"q {rgb[0]:.3f} {rgb[1]:.3f} {rgb[2]:.3f} rg BT {font} {size} Tf "
                f"{x + shift:.2f} {y:.2f} Td ({_esc(s)}) Tj ET Q"
            )

    def content(self) -> bytes:
        return ("\n".join(self.ops) + "\n").encode("latin-1", "replace")


class Document:
    def __init__(self, size=LETTER) -> None:
        self.size = size
        self.pages: list[Page] = []

    def new_page(self) -> Page:
        page = Page(*self.size)
        self.pages.append(page)
        return page

    def save(self, path) -> None:
        objects: list[bytes] = []

        def add(body: bytes) -> int:
            objects.append(body)
            return len(objects)  # 1-based object number

        font_regular = add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica "
                           b"/Encoding /WinAnsiEncoding >>")
        font_bold = add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold "
                        b"/Encoding /WinAnsiEncoding >>")
        pages_id = add(b"")  # reserved, filled in once page ids are known

        page_ids = []
        for page in self.pages:
            stream = page.content()
            content_id = add(
                b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n"
                + stream + b"endstream"
            )
            page_ids.append(add(
                f"<< /Type /Page /Parent {pages_id} 0 R "
                f"/MediaBox [0 0 {page.width:.2f} {page.height:.2f}] "
                f"/Resources << /Font << /F1 {font_regular} 0 R /F2 {font_bold} 0 R >> >> "
                f"/Contents {content_id} 0 R >>".encode()
            ))

        kids = " ".join(f"{pid} 0 R" for pid in page_ids)
        objects[pages_id - 1] = (
            f"<< /Type /Pages /Count {len(page_ids)} /Kids [{kids}] >>".encode()
        )
        catalog_id = add(f"<< /Type /Catalog /Pages {pages_id} 0 R >>".encode())

        out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
        offsets = [0]
        for number, body in enumerate(objects, start=1):
            offsets.append(len(out))
            out += f"{number} 0 obj\n".encode() + body + b"\nendobj\n"

        xref_at = len(out)
        out += f"xref\n0 {len(objects) + 1}\n".encode()
        out += b"0000000000 65535 f \n"
        for off in offsets[1:]:
            out += f"{off:010d} 00000 n \n".encode()
        out += (
            f"trailer\n<< /Size {len(objects) + 1} /Root {catalog_id} 0 R >>\n"
            f"startxref\n{xref_at}\n%%EOF\n"
        ).encode()

        with open(path, "wb") as handle:
            handle.write(bytes(out))
