#!/usr/bin/python3
# sg-pdf -- SG PDF's Linux half: MuPDF (Debian's python3-pymupdf) reads the
# document, renders its pages to bitmaps, reports their text layout, links,
# outline and search hits, and makes every edit -- text and pictures,
# comments, forms and signatures, true redaction, pages, passwords, export
# (the engine: pdf/sgpdf.py, installed in /usr/lib/stained-glass/pdf).
# sg-shell's sg-pdf64.exe (the Windows program) draws them and drives it.
#
#   sg-pdf --bridge PROGRAM [ARGS]  run PROGRAM (the Windows viewer, through
#                                   wine) with pipes as its stdin and stdout
#                                   and serve its requests: how a Windows
#                                   program reaches this (Wine gives a Windows
#                                   program no AF_UNIX and no pipe to a native
#                                   one), as sg-dictate and sg-netctl do
#   sg-pdf --serve                  serve requests on our own stdin/stdout
#   sg-pdf --info FILE              pages and page sizes (for scripts)
#   sg-pdf --thumbnail FILE SIZE OUT
#                                   the first page as a PNG whose longer side
#                                   is SIZE pixels (16-1024), written to OUT
#                                   whole (renamed into place); on failure
#                                   OUT.err holds the reason. File Explorer's
#                                   PDF thumbnails (wine-sg 0244): Wine gives
#                                   shell32 no pipe to a native program, so
#                                   the answer is a file it waits for
#
# Protocol: one request a line, fields separated by tabs:
#
#   open PATH [PASSWORD]      the document (a Unix path)
#   render PAGE SCALE ROT     page (0-based) at SCALE pixels a point, turned
#                             ROT degrees clockwise (0, 90, 180, 270)
#   text PAGE                 the page's text and each character's box
#   find NEEDLE [FLAGS]       every hit in the document; FLAGS: c = match case,
#                             w = whole words only
#   links PAGE                the page's links
#   outline                   the document's bookmarks
#   quit
#
# A request may start "@N<TAB>": it goes to document N's own engine process
# (started when first named; "@N<TAB>quit" ends it). SG PDF opens each
# document -- each tab -- in an engine of its own.
#
# Every answer is one line, "OK key=value ..." or "ERR kind message" (kind:
# invalid, open, password, notopen, range, toolarge, failed). An OK line with
# bytes=N is followed by exactly N bytes:
#
#   open     "size W H" a page (points), then "title T", "author A" lines
#   render   w=W h=H: W*H*4 bytes, top-down rows of B,G,R,A (opaque)
#   text     n=N: N UTF-16LE code units, then N boxes of four little-endian
#            float32 (x1 y1 x2 y2, points, origin top-left, page unturned);
#            a character beyond the BMP has its box twice, one per unit
#   find     "PAGE X1 Y1 X2 Y2" lines (points, origin top-left)
#   links    "X1 Y1 X2 Y2<TAB>goto<TAB>PAGE<TAB>TOP" or "...<TAB>uri<TAB>URI"
#   outline  "DEPTH<TAB>PAGE<TAB>TOP<TAB>OPEN<TAB>TITLE" lines, in order (PAGE
#            -1: none; OPEN 1: the document shows it expanded)
#
# The editor's requests (pdf/sgpdf.py's COMMANDS; text fields escape \t \n
# \r and \\ with a backslash; options are key=value fields):
#
#   state | undo | redo | save PATH [apply=1] | properties title=..
#   objects PAGE            text blocks, images, paths: "KIND ID RECT ..."
#   edittext PAGE ID RECT TEXT [font= size= color= bold= italic= align=]
#   addtext PAGE RECT TEXT [...] | addimage PAGE RECT FILE
#   moveobj PAGE ID RECT | delobj PAGE ID | replaceimage PAGE ID FILE
#   annot PAGE KIND key=value.. (highlight underline strikeout squiggly
#     note freetext rect ellipse line arrow ink) | annots [PAGE]
#   delannot PAGE XREF | moveannot PAGE XREF RECT | setannot PAGE XREF ..
#   fields | setfield PAGE XREF VALUE | flatten [forms=1 annots=0]
#   signature PAGE RECT ink|text|image DATA
#   redactmark PAGE rects=.. | redactfind WHAT [FLAGS [MARK]] (WHAT may be
#     pattern:phone|email|ssn|card|date) | redactapply | sanitize [what=0..]
#   rotate PAGES DEG | delete PAGES | move PAGES TO | insertblank AT [W H]
#   insertfile AT FILE [PW] | extract PAGES FILE [DELETE] | split N DIR BASE
#   combine OUT FILE.. | protect mode=aes256|none user= owner= perms=..
#   unlock OWNERPW | export png|jpeg|txt|html|docx FILE [pages= dpi=]
#
# The professional tools (Prepare Form, certificate signatures, Create PDF,
# OCR, decorations, attachments, replies; see sg-session's CLAUDE.md):
#   addfield fieldprops setfieldprops delfield movefield formdetect
#   resetform fillmark | certsign signatures makeid idinfo trustsigner |
#   new create scanners scan ocrlangs ocr | decorate optimize |
#   attachments getattachment addattachment delattachment | replacepages
#   addlink dellink setoutline | reply setstatus thread | speak
#
# A request that changes the document answers like open, with its state
# (undo= redo= dirty= perms= encrypted= protect= form= redactions=) in
# the header; PAGES are 0-based lists ("0,2,4-6").
#
# Only the files the program names are read and written; nothing touches
# the network.
#
# Copyright (C) 2026 Stained Glass OS contributors
# SPDX-License-Identifier: AGPL-3.0-or-later

import os
import select
import subprocess
import sys


def _engine_path():
    here = os.path.dirname(os.path.realpath(__file__))
    for d in (os.environ.get("SG_PDF_LIB"), os.path.join(here, "..", "pdf"), "/usr/lib/stained-glass/pdf"):
        if d and os.path.isfile(os.path.join(d, "sgpdf.py")):
            return d
    return "/usr/lib/stained-glass/pdf"


sys.path.insert(0, _engine_path())
try:
    import sgpdf  # noqa: E402
    HAVE_ENGINE = True
except ImportError as e:  # the package's Depends bring python3-pymupdf
    HAVE_ENGINE = False
    ENGINE_ERROR = str(e)

MAX_REQUEST = 1 << 20
# SG_PDF_TRACE=<file>: every request and its answer's first line, appended (debugging)
TRACE = os.environ.get("SG_PDF_TRACE")


def handle(doc, line):
    if not HAVE_ENGINE:
        if line.split("\t")[0] == "quit":
            return None
        return "ERR failed MuPDF (python3-pymupdf) is not installed: " + " ".join(ENGINE_ERROR.split()), b""
    return sgpdf.handle(doc, line)


def new_engine():
    return sgpdf.Engine() if HAVE_ENGINE else None


MAX_ENGINES = 32


class Engines:
    """One engine process a document: a request "@N<TAB>..." goes to engine N
    (`sg-pdf --serve`, started the first time it is named), "@N<TAB>quit" ends
    it. Each document is then its own process -- its memory is given back when
    its tab closes, a crash takes only it -- and an idle one costs nothing but
    its pages of memory (it sleeps in read). A request without "@N" is this
    process's own engine, as before."""

    def __init__(self):
        self.procs = {}

    def ask(self, n, line):
        p = self.procs.get(n)
        if line.split("\t")[0] == "quit":
            if p:
                self.stop(n)
            return "OK", b""
        if p is None:
            if len(self.procs) >= MAX_ENGINES:
                return "ERR toolarge too many documents are open", b""
            try:
                p = subprocess.Popen([sys.executable, os.path.realpath(__file__), "--serve"],
                                     stdin=subprocess.PIPE, stdout=subprocess.PIPE, close_fds=True)
            except OSError as e:
                return "ERR failed could not start the document's engine: " + " ".join(str(e).split()), b""
            self.procs[n] = p
        try:
            p.stdin.write(line.encode("utf-8") + b"\n")
            p.stdin.flush()
            head = p.stdout.readline()
            if not head:
                raise BrokenPipeError()
            head = head.decode("utf-8", "replace").rstrip("\n")
            m = head.rsplit("bytes=", 1)
            data = b""
            if len(m) == 2 and (m[0] == "" or m[0].endswith(" ")) and m[1].isdigit():
                want = int(m[1])
                while len(data) < want:
                    chunk = p.stdout.read(want - len(data))
                    if not chunk:
                        raise BrokenPipeError()
                    data += chunk
            return head, data
        except (BrokenPipeError, OSError):
            self.stop(n)
            return "ERR failed the document's engine stopped", b""

    def stop(self, n):
        p = self.procs.pop(n, None)
        if not p:
            return
        try:
            p.stdin.close()
        except OSError:
            pass
        try:
            p.wait(5)
        except subprocess.TimeoutExpired:
            p.kill()
            p.wait()

    def stop_all(self):
        for n in list(self.procs):
            self.stop(n)


def serve(fd_in, out, alive=None):
    """Answer requests from fd_in on out (a binary file) until quit or EOF.
    `alive` is polled while idle: the bridge's child may exit while a process
    it started (wineserver) still holds the pipe open."""
    engines = Engines()
    try:
        return _serve(fd_in, out, alive, engines)
    finally:
        engines.stop_all()


def _serve(fd_in, out, alive, engines):
    doc = new_engine()
    pending = b""
    while True:
        if alive is not None:
            ready, _, _ = select.select([fd_in], [], [], 1.0)
            if not ready:
                if not alive():
                    return 0
                continue
        chunk = os.read(fd_in, 65536)
        if not chunk:
            return 0
        pending += chunk
        if len(pending) > MAX_REQUEST and b"\n" not in pending:
            pending = b""
        while b"\n" in pending:
            raw, pending = pending.split(b"\n", 1)
            line = raw.decode("utf-8", "replace").strip("\r")
            if not line:
                continue
            engine = None
            if line.startswith("@") and "\t" in line:
                tag, rest = line.split("\t", 1)
                if tag[1:].isdigit():
                    engine, line = int(tag[1:]), rest
            if TRACE:
                secret = line.startswith(("protect", "unlock", "certsign", "makeid", "idinfo")) or (line.startswith("open\t") and line.count("\t") >= 2)
                with open(TRACE, "a") as t:
                    t.write(("@%d " % engine if engine is not None else "") +
                            (line.split("\t")[0] + "\t(passwords not logged)" if secret else line[:2000]) + "\n")
            # the gate's mutant: every document in one engine (test/pdf-pro-mutants.sh ENGINES)
            if engine is not None and os.environ.get("SG_PDF_MUTANT_ONE_ENGINE"):
                engine = 1
            ans = engines.ask(engine, line) if engine is not None else handle(doc, line)
            if TRACE and ans:
                with open(TRACE, "a") as t:
                    t.write("  -> " + ans[0][:300] + "\n")
            if ans is None:
                return 0
            head, data = ans
            try:
                out.write(head.encode("utf-8") + b"\n" + data)
                out.flush()
            except BrokenPipeError:
                return 0


def bridge(program):
    """Run the Windows viewer with pipes as its stdin/stdout and serve it."""
    down_r, down_w = os.pipe()
    up_r, up_w = os.pipe()
    child = subprocess.Popen(program, stdin=down_r, stdout=up_w, close_fds=True)
    os.close(down_r)
    os.close(up_w)
    out = os.fdopen(down_w, "wb")
    serve(up_r, out, alive=lambda: child.poll() is None)
    try:
        out.close()
    except BrokenPipeError:
        pass
    return child.wait()


def thumbnail(path, size, out):
    """page 1 as a PNG no larger than size x size, written whole to out"""
    def fail(why):
        try:
            with open(out + ".err.tmp", "w") as f:
                f.write(why + "\n")
            os.replace(out + ".err.tmp", out + ".err")
        except OSError:
            pass
        return 1
    try:
        size = int(size)
    except ValueError:
        return fail("invalid size")
    if not 16 <= size <= 1024:
        return fail("invalid size")
    if not HAVE_ENGINE:
        return fail("MuPDF (python3-pymupdf) is not installed")
    doc = sgpdf.Engine()
    head, _ = doc.open(path)
    if not head.startswith("OK") or not doc.doc.page_count:
        return fail(head)
    r = doc.doc[0].rect
    if r.width <= 0 or r.height <= 0:
        return fail("empty page")
    scale = min(size / max(r.width, r.height), 64)
    try:
        pix = doc.doc[0].get_pixmap(matrix=sgpdf.fitz.Matrix(scale, scale), alpha=False, annots=True)
        pix.save(out + ".tmp", output="png")
        os.replace(out + ".tmp", out)
    except Exception as e:  # MuPDF's own errors, and OSError
        return fail(" ".join(str(e).split()))
    return 0


def main():
    args = sys.argv[1:]
    if args[:1] == ["--thumbnail"] and len(args) == 4:
        return thumbnail(args[1], args[2], args[3])
    if args[:1] == ["--bridge"] and len(args) > 1:
        return bridge(args[1:])
    if args[:1] == ["--serve"]:
        return serve(sys.stdin.fileno(), sys.stdout.buffer)
    if args[:1] == ["--info"] and len(args) == 2:
        if not HAVE_ENGINE:
            print("ERR failed MuPDF (python3-pymupdf) is not installed")
            return 1
        doc = sgpdf.Engine()
        head, data = doc.open(args[1])
        print(head)
        sys.stdout.write(data.decode("utf-8"))
        return 0 if head.startswith("OK") else 1
    print("usage: sg-pdf --bridge PROGRAM [ARGS] | --serve | --info FILE | --thumbnail FILE SIZE OUT", file=sys.stderr)
    return 2


if __name__ == "__main__":
    sys.exit(main())
