#!/usr/bin/env python3
"""CUPS backend wrapper: map PPD options to the IPP names Mobility Print uses.

The CUPS `ipp` backend normally translates PPD options such as `ColorModel`,
`PageSize` and `Duplex` into IPP attributes.  That translation is skipped for
the generic AirPrint PPD delivered by Print Deploy because the Mobility Print
server encodes `media-col-supported` as a collection, while CUPS expects the
keyword list form; the backend then falls back to sending the PPD option names
verbatim, and Mobility Print ignores them.

This wrapper sits between CUPS and the stock `ipp` backend.  It rewrites the
job options string before exec'ing the stock backend:

  ColorModel=RGB|CMYK|CMY        -> print-color-mode=color
  ColorModel=Gray|FastGray       -> print-color-mode=monochrome
  SelectColor=Color|Auto         -> print-color-mode=color      (KM PPDs)
  SelectColor=Grayscale          -> print-color-mode=monochrome
  PageSize=A3 (or any PPD size)  -> media-col={media-size={x-dimension=... y-dimension=...}}
  Duplex=None                    -> sides=one-sided
  Duplex=DuplexNoTumble          -> sides=two-sided-long-edge
  Duplex=DuplexTumble            -> sides=two-sided-short-edge
  KMDuplex=1Sided|2Sided         -> sides=one-sided|two-sided-long-edge

Explicit IPP names (`print-color-mode`, `media`, `media-col`, `sides`) are
left alone and take precedence.  The document itself is not touched; the stock
`ipp` backend still builds and sends the IPP request to the relay.

The queue's device URI must use this wrapper's scheme:

    sudo install -m 0755 papercut_relay_backend.py \
        /usr/lib/cups/backend/papercut-relay
    sudo lpadmin -p PaperCut-FollowMe -E \
        -v papercut-relay:/ \
        -P /usr/share/ppd/Follow-Me.ppd \
        -o printer-is-shared=false -o auth-info-required=none

The wrapper then sets DEVICE_URI to the local relay (default
ipp://127.0.0.1:9123/; the relay ignores the path and uses its config) and
execs the stock backend.  Override the relay URI with PAPERCUT_RELAY_URI.

This is a mapping shim for the generic AirPrint PPD, not a replacement for the
Konica Minolta PostScript driver.  The KM PPD's KMbeuEmpPS.pl filter is not used
with the relay: it emits a PJL/PostScript stream for a direct printer
connection, while the Mobility Print queue accepts application/pdf and
image/urf.  If you want the vendor C451i option set, make_relay_ppd.py creates
a variant of the multi-model vendor PPD with the KM filter replaced by the
PDF/URF pass-through chain; use that PPD with this wrapper.

Run it by hand for a quick check (the stock backend will try to print):

    PPD=/etc/cups/ppd/PaperCut-FollowMe.ppd PAPERCUT_RELAY_URI=... \
        papercut-relay 1 user title 1 "ColorModel=RGB PageSize=A3 Duplex=DuplexNoTumble"
"""

from __future__ import annotations

import os
import re
import sys

DEFAULT_RELAY_URI = "ipp://127.0.0.1:9123/"
LOGGER = "papercut-relay"

# PWG media names for PPD sizes when the PPD has no PaperDimension entry.
MEDIA_NAMES = {
    "A3": "iso_a3",
    "A4": "iso_a4",
    "A5": "iso_a5",
    "A6": "iso_a6",
    "B4": "iso_b4",
    "B5": "iso_b5",
    "Letter": "na_letter",
    "Legal": "na_legal",
    "Tabloid": "na_ledger",
    "Ledger": "na_ledger",
    "Executive": "na_executive",
    "Statement": "na_statement",
}

# Canonical PWG dimensions (hundredths of a millimetre) for common sizes.
# These match the captured media-col-supported values exactly and avoid
# rounding differences between vendor PPDs.
MEDIA_DIMENSIONS = {
    "A3": (29700, 42000),
    "A4": (21000, 29700),
    "A5": (14800, 21000),
    "A6": (10500, 14800),
    "Letter": (21590, 27940),
    "Legal": (21590, 35560),
    "Tabloid": (27940, 43180),
    "Ledger": (27940, 43180),
    "Executive": (18410, 26670),
    "Statement": (13970, 21590),
}

COLOR_MONO = {"Gray", "FastGray", "DeviceGray", "Grayscale"}
COLOR_COLOR = {"RGB", "CMYK", "CMY", "Color"}

DUPLEX_SIDES = {
    "None": "one-sided",
    "1Sided": "one-sided",
    "DuplexNoTumble": "two-sided-long-edge",
    "2Sided": "two-sided-long-edge",
    "DuplexTumble": "two-sided-short-edge",
}


def log(message: str):
    print(f"[{LOGGER}] {message}", file=sys.stderr, flush=True)


def split_options(text: str):
    """Split a CUPS options string, respecting quotes and {...} collections."""
    tokens = []
    buf = []
    quote = None
    depth = 0
    i = 0
    while i < len(text):
        ch = text[i]
        if quote:
            if ch == "\\" and i + 1 < len(text):
                buf.append(text[i + 1])
                i += 2
                continue
            if ch == quote:
                quote = None
            else:
                buf.append(ch)
            i += 1
            continue
        if ch in ('"', "'"):
            quote = ch
            i += 1
            continue
        if ch == "{":
            depth += 1
            buf.append(ch)
            i += 1
            continue
        if ch == "}":
            depth = max(0, depth - 1)
            buf.append(ch)
            i += 1
            continue
        if ch.isspace() and depth == 0:
            if buf:
                tokens.append("".join(buf))
                buf = []
            i += 1
            continue
        if ch == "\\" and i + 1 < len(text):
            buf.append(text[i + 1])
            i += 2
            continue
        buf.append(ch)
        i += 1
    if buf:
        tokens.append("".join(buf))
    return tokens


def quote_option(token: str) -> str:
    """Quote one name=value token for cupsParseOptions().

    Only the value may be quoted; cupsParseOptions() treats a quote before
    the option name as part of the name.  Collection values ({...}) are
    parsed with brace nesting, so they keep their spaces unquoted.
    """
    name, sep, value = token.partition("=")
    if not sep or not any(ch.isspace() for ch in value):
        return token
    if value.startswith("{"):
        return f"{name}={value}"
    escaped = value.replace("\\", "\\\\").replace('"', '\\"')
    return f'{name}="{escaped}"'


def load_ppd_sizes(path):
    """Return {PageSize choice: (x, y) in hundredths of a millimetre}."""
    sizes = {}
    if not path:
        return sizes
    try:
        with open(path, encoding="utf-8", errors="replace") as fh:
            for line in fh:
                match = re.match(
                    r'^\*PaperDimension\s+(\S+):\s*"([^"]+)"', line
                )
                if not match:
                    continue
                # Vendor PPDs often write "*PaperDimension A3/A3: ...".
                name = match.group(1).split("/", 1)[0]
                value = match.group(2).split()
                if len(value) < 2:
                    continue
                try:
                    width_pt, height_pt = float(value[0]), float(value[1])
                except ValueError:
                    continue
                # PPD PaperDimension is in PostScript points; 1 pt = 25.4/72 mm.
                width = round(width_pt * 100 * 25.4 / 72)
                height = round(height_pt * 100 * 25.4 / 72)
                if width > 0 and height > 0:
                    sizes[name] = (width, height)
    except OSError as exc:
        log(f"cannot read PPD {path}: {exc}")
    return sizes


def media_col(width: int, height: int) -> str:
    return (
        "media-col={media-size={x-dimension=%d y-dimension=%d}}"
        % (width, height)
    )


def parse_custom_mm(value: str):
    match = re.fullmatch(r"(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)mm", value)
    if not match:
        return None
    return (round(float(match.group(1)) * 100),
            round(float(match.group(2)) * 100))


def map_options(options: str, ppd_sizes):
    """Return (new_options, changes) with PPD options mapped to IPP names."""
    tokens = split_options(options)

    values = {}
    for token in tokens:
        name, sep, value = token.partition("=")
        if sep and name not in values:
            values[name] = value

    explicit_color = "print-color-mode" in values
    explicit_media = "media" in values or "media-col" in values
    explicit_sides = "sides" in values

    out = []
    changes = []
    seen = set()
    for token in tokens:
        name, sep, value = token.partition("=")
        if not sep:
            out.append(token)
            continue

        if name in ("ColorModel", "SelectColor"):
            if explicit_color or "print-color-mode" in seen:
                continue
            if value in COLOR_MONO:
                color = "monochrome"
            elif value in COLOR_COLOR:
                color = "color"
            else:
                # e.g. SelectColor=Auto: leave the printer's default alone.
                log(f"no color mapping for {name}={value!r}; leaving it unchanged")
                out.append(token)
                continue
            out.append(f"print-color-mode={color}")
            seen.add("print-color-mode")
            changes.append(f"{name}={value} -> print-color-mode={color}")
            continue

        if name == "PageSize":
            if explicit_media or "media-col" in seen or "media" in seen:
                continue
            size = (
                MEDIA_DIMENSIONS.get(value)
                or ppd_sizes.get(value)
                or parse_custom_mm(value)
            )
            if size:
                out.append(media_col(*size))
                seen.add("media-col")
                changes.append(
                    f"PageSize={value} -> media-col={size[0]//100}x{size[1]//100}mm"
                )
                continue
            if value in MEDIA_NAMES:
                out.append(f"media={MEDIA_NAMES[value]}")
                seen.add("media")
                changes.append(f"PageSize={value} -> media={MEDIA_NAMES[value]}")
                continue
            log(f"no media mapping for PageSize={value!r}; leaving it unchanged")
            out.append(token)
            continue

        if name in ("Duplex", "KMDuplex"):
            if explicit_sides or "sides" in seen:
                continue
            sides = DUPLEX_SIDES.get(value)
            if name == "KMDuplex" and value == "2Sided" and \
                    values.get("Binding") == "TopBinding":
                sides = "two-sided-short-edge"
            if sides:
                out.append(f"sides={sides}")
                seen.add("sides")
                changes.append(f"{name}={value} -> sides={sides}")
                continue
            log(f"no duplex mapping for {name}={value!r}; leaving it unchanged")
            out.append(token)
            continue

        out.append(token)

    return " ".join(quote_option(t) for t in out), changes


def find_stock_backend():
    serverbin = os.environ.get("CUPS_SERVERBIN", "/usr/lib/cups")
    candidates = [
        os.path.join(serverbin, "backend", "ipp"),
        "/usr/lib/cups/backend/ipp",
        "/usr/libexec/cups/backend/ipp",
        "/usr/local/lib/cups/backend/ipp",
    ]
    for path in candidates:
        if os.path.isfile(path) and os.access(path, os.X_OK):
            return path
    return None


def main(argv) -> int:
    if not argv:
        # CUPS device discovery (lpinfo -v).
        print('direct papercut-relay:/ "PaperCut Mobility Print relay" '
              '"MFG:PaperCut;MDL:Mobility Print relay;"')
        return 0

    if len(argv) < 5:
        log("usage: papercut-relay job-id user title copies options [file]")
        return 1

    options = argv[4]
    ppd_sizes = load_ppd_sizes(os.environ.get("PPD"))
    new_options, changes = map_options(options, ppd_sizes)
    for change in changes:
        log(change)

    stock = find_stock_backend()
    if not stock:
        log("cannot find the CUPS ipp backend")
        return 1

    relay_uri = os.environ.get("PAPERCUT_RELAY_URI", DEFAULT_RELAY_URI)
    env = os.environ.copy()
    env["DEVICE_URI"] = relay_uri
    new_argv = [stock, argv[0], argv[1], argv[2], argv[3], new_options]
    new_argv.extend(argv[5:])
    try:
        os.execve(stock, new_argv, env)
    except OSError as exc:
        log(f"cannot exec {stock}: {exc}")
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
