"""DocuDeck Linux renderer protocol v1. stdout contains one JSON document only."""
import json
import os
import pathlib
import shutil
import subprocess
import sys
import zipfile
from lxml import etree as ET
from fontTools import version as fonttools_version
from fontTools.ttLib import TTFont

def fromstring(data):
    if isinstance(data, str): data = data.encode('utf-8')
    if b"<!DOCTYPE" in data.upper() or b"<!ENTITY" in data.upper():
        raise ValueError("Unsafe presentation XML")
    return ET.fromstring(data, ET.XMLParser(resolve_entities=False, load_dtd=False, no_network=True))

if sys.platform == "linux":
    import resource
    resource.setrlimit(resource.RLIMIT_AS, (2 * 1024**3, 2 * 1024**3))
    resource.setrlimit(resource.RLIMIT_CPU, (900, 900))

A = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
P = "{http://schemas.openxmlformats.org/presentationml/2006/main}"

def run(args, env=None):
    result = subprocess.run(args, env=env, capture_output=True, text=True, timeout=850, check=True)
    return result.stdout

def environment():
    tools = ["soffice", "pdftoppm", "pdfinfo", "fc-list", "fc-match"]
    missing = [tool for tool in tools if not shutil.which(tool)]
    locales = run(["locale", "-a"]).lower() if shutil.which("locale") else ""
    for locale in ["en_us.utf8", "ar_sa.utf8"]:
        if locale not in locales:
            missing.append(locale)
    fonts = run(["fc-list", "--format", "%{family}\t%{style}\n"]).splitlines() if not missing else []
    return {"version": 1, "available": not missing, "missing": missing, "system_fonts": fonts, "fonttools_version": fonttools_version}

def font_env(job, font_dir):
    # No host/global cache or configuration is written. Uploaded directories go first.
    root = ET.Element("fontconfig")
    for directory in [font_dir, "/usr/share/fonts", "/usr/local/share/fonts"]:
        ET.SubElement(root, "dir").text = str(directory)
    ET.SubElement(root, "cachedir").text = str(job / "cache")
    # Full face names in PPTX are not necessarily Fontconfig family names.
    from fontTools.ttLib import TTFont
    for file in sorted(font_dir.glob("*")):
        if file.suffix.lower() not in (".ttf", ".otf"):
            continue
        with TTFont(file) as font:
            names = font["name"]
            family = names.getBestFamilyName()
            style = names.getBestSubFamilyName() or "Regular"
            aliases = {record.toUnicode().strip() for record in names.names if record.nameID in (1, 4, 6, 16)}
        for alias in sorted(aliases):
            if not alias or alias == family:
                continue
            match = ET.SubElement(root, "match", target="pattern")
            test = ET.SubElement(match, "test", name="family", compare="eq")
            ET.SubElement(test, "string").text = alias
            edit = ET.SubElement(match, "edit", name="family", mode="assign", binding="strong")
            ET.SubElement(edit, "string").text = family
            edit = ET.SubElement(match, "edit", name="style", mode="assign", binding="strong")
            ET.SubElement(edit, "string").text = style
    config = job / "fonts.conf"
    ET.ElementTree(root).write(config, encoding="utf-8", xml_declaration=True)
    return dict(os.environ, FONTCONFIG_FILE=str(config), FONTCONFIG_PATH=str(job), XDG_CACHE_HOME=str(job / "cache"))

def prepare(source, target, language, direction):
    resolved = ("rtl" if language == "ar" else "ltr") if direction == "auto" else direction
    with zipfile.ZipFile(source) as src, zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as dst:
        for entry in src.infolist():
            data = src.read(entry)
            if entry.filename == "ppt/presentation.xml":
                root = fromstring(data)
                # Put defaults at the lowest-priority presentation level, not in individual
                # paragraphs: direct, layout and master formatting must retain precedence.
                style = root.find(P + "defaultTextStyle")
                if style is None:
                    style = ET.Element(P + "defaultTextStyle")
                    before = next((child for child in root if child.tag in (P + "modifyVerifier", P + "extLst")), None)
                    if before is None:
                        root.append(style)
                    else:
                        root.insert(list(root).index(before), style)
                props = style.find(A + "defPPr")
                if props is None:
                    props = ET.Element(A + "defPPr")
                    style.insert(0, props)
                if "rtl" not in props.attrib:
                    props.set("rtl", "1" if resolved == "rtl" else "0")
                defaults = props.find(A + "defRPr")
                if defaults is None:
                    defaults = ET.SubElement(props, A + "defRPr")
                if "lang" not in defaults.attrib:
                    defaults.set("lang", "ar-SA" if language == "ar" else "en-US")
                data = ET.tostring(root, encoding="utf-8", xml_declaration=True)
            dst.writestr(entry, data)

def configured_environment(job, settings):
    env = font_env(job, job / "fonts")
    for font in settings.get("fonts", []):
        metadata = font["metadata"]
        selected = run(["fc-match", "--format", "%{file}", metadata["family"] + ":style=" + metadata["style"]], env).strip()
        if pathlib.Path(selected).parent.resolve() != (job / "fonts").resolve():
            raise ValueError("Uploaded font family/style was not selected: " + metadata["family"])
    env.update(LANG="ar_SA.UTF-8" if settings["language"] == "ar" else "en_US.UTF-8", LC_ALL="ar_SA.UTF-8" if settings["language"] == "ar" else "en_US.UTF-8")
    return env

def prepare_presentation(job, settings, env):
    prepare(job / "source.pptx", job / "prepared.pptx", settings["language"], settings["direction"])
    if settings.get("corrections"):
        from layout import apply
        corrections = settings["corrections"]
        for values in corrections.values():
            if "font_style" in values:
                path = run(["fc-match", "--format", "%{file}", values["font_family"] + ":style=" + values["font_style"]], env).strip()
                with TTFont(path) as selected:
                    values["font_family"] = selected["name"].getDebugName(4) or values["font_family"]
        apply(job / "prepared.pptx", job / "corrected.pptx", corrections)
        (job / "corrected.pptx").replace(job / "prepared.pptx")

def pdf_filter(page_numbers=None):
    if not page_numbers:
        return "pdf"
    page_range = ",".join(str(int(number)) for number in page_numbers)
    options = json.dumps({"PageRange": {"type": "string", "value": page_range}}, separators=(",", ":"))
    return "pdf:impress_pdf_Export:" + options

def export_pdf(job, env, page_numbers=None):
    output = job / "prepared.pdf"
    output.unlink(missing_ok=True)
    run(["soffice", "--headless", "--nologo", "--nofirststartwizard", "-env:UserInstallation=" + (job / "profile").as_uri(), "--convert-to", pdf_filter(page_numbers), "--outdir", str(job), str(job / "prepared.pptx")], env)
    if not output.is_file():
        raise ValueError("Preview PDF was not created" if page_numbers else "PDF was not created")

def pdf_page_count(path, env):
    for line in run(["pdfinfo", str(path)], env).splitlines():
        if line.lower().startswith("pages:"):
            return int(line.split(":", 1)[1].strip())
    raise ValueError("PDF page count is unavailable")

def export_preview_pdf(job, env, targets, slide_count):
    try:
        export_pdf(job, env, targets)
        if pdf_page_count(job / "prepared.pdf", env) == len(targets):
            return True
    except Exception:
        # Older LibreOffice builds may reject JSON PDF filter options. Keep
        # the proven full-document path as a compatibility fallback.
        pass
    export_pdf(job, env)
    if pdf_page_count(job / "prepared.pdf", env) != int(slide_count):
        raise ValueError("Preview PDF page count mismatch")
    return False

def prepare_pdf(job, settings, env):
    prepare_presentation(job, settings, env)
    export_pdf(job, env)

def render(job):
    settings = json.loads((job / "request.json").read_text())
    env = configured_environment(job, settings)
    prepare_pdf(job, settings, env)
    for folder, size, quality in [("slides", 1920, 90), ("thumbnails", 320, 75)]:
        output = job / folder
        output.mkdir()
        run(["pdftoppm", "-jpeg", "-jpegopt", f"quality={quality}", "-scale-to", str(size), str(job / "prepared.pdf"), str(output / "slide")], env)
        files = sorted(output.glob("slide-*.jpg"), key=lambda f: int(f.stem.split("-")[-1]))
        if len(files) != settings["slide_count"]:
            raise ValueError("Rendered slide count mismatch")
        for index, file in enumerate(files, 1):
            if file.stat().st_size < 4 or file.read_bytes()[:2] != b"\xff\xd8":
                raise ValueError("Invalid rendered image")
            file.rename(output / f"{index:03d}.jpg")
    return {"version": 1, "slides": settings["slide_count"]}

def emit(event):
    print(json.dumps(event), flush=True)

def preview(job):
    settings = json.loads((job / "request.json").read_text())
    targets = []
    for value in settings.get("target_slides", []):
        number = int(value)
        if number < 1 or number > int(settings["slide_count"]):
            raise ValueError("Invalid preview slide")
        if number not in targets:
            targets.append(number)
    if not targets:
        raise ValueError("No preview slides requested")
    env = configured_environment(job, settings)
    emit({"event": "phase", "phase": "preparing_selected_pdf", "target_slides": targets})
    prepare_presentation(job, settings, env)
    selected_pdf = export_preview_pdf(job, env, targets, settings["slide_count"])
    preview_mode = settings.get("preview_mode", "corrected")
    emit({"event": "phase", "phase": "original_slides" if preview_mode == "original" else "corrected_slides"})
    output = job / "slides"
    output.mkdir(exist_ok=True)
    failed = []
    for target_index, number in enumerate(targets, 1):
        pdf_page = target_index if selected_pdf else number
        emit({"event": "slide_started", "slide": number})
        temporary = output / f".{number:03d}"
        try:
            run(["pdftoppm", "-f", str(pdf_page), "-l", str(pdf_page), "-singlefile", "-jpeg", "-jpegopt", f"quality={int(settings.get('preview_quality', 82))}", "-scale-to", str(int(settings.get("preview_size", 1280))), str(job / "prepared.pdf"), str(temporary)], env)
            image = pathlib.Path(str(temporary) + ".jpg")
            if not image.is_file() or image.stat().st_size < 4 or image.read_bytes()[:2] != b"\xff\xd8":
                raise ValueError("Invalid rendered image")
            image.replace(output / f"{number:03d}.jpg")
            emit({"event": "slide_ready", "slide": number})
        except Exception as error:
            failed.append(number)
            for candidate in output.glob(f".{number:03d}*"):
                candidate.unlink(missing_ok=True)
            emit({"event": "slide_failed", "slide": number, "error": str(error)[:500]})
    emit({"event": "complete", "ready": len(targets) - len(failed), "failed_slides": failed})
    return {"version": 2, "slides": targets, "failed_slides": failed}

if __name__ == "__main__":
    try:
        command = sys.argv[1]
        if command == "layout":
            from layout import extract
            result = extract(pathlib.Path(sys.argv[2]) / "source.pptx")
        else:
            result = environment() if command == "environment" else render(pathlib.Path(sys.argv[2]).resolve()) if command == "render" else preview(pathlib.Path(sys.argv[2]).resolve()) if command == "preview" else None
        if result is None:
            raise ValueError("Unsupported command")
        print(json.dumps(result))
    except Exception as error:
        print(json.dumps({"version": 1, "error": str(error)}))
        sys.exit(1)
