"""Shared format-to-PDF conversion utilities.

Input path (any format → PDF):
  - LibreOffice (Office documents, OpenDocument, RTF, HTML, …)
  - img2pdf     (raster images)

Output path (assembled PDF → any format):
  - LibreOffice (DOCX, PPTX, ODT, ODP, HTML, …)
  - pypdfium2   (PNG, JPEG, TIFF — single page as file, multi-page as ZIP)
"""

import io
import os
import platform
import shutil
import subprocess
import sys
import tempfile
import uuid
import zipfile
from pathlib import Path

import logging
import threading

import img2pdf
import markdown as markdown_lib
import pypdfium2 as pdfium

logging.getLogger("img2pdf").setLevel(logging.ERROR)

# pypdfium2 is not thread-safe — serialise all rendering calls.
_pdfium_lock = threading.Lock()

# LibreOffice subprocess timeout in seconds. Override via CLI --lo-timeout.
LO_TIMEOUT = 300


# ---------------------------------------------------------------------------
# Input format sets  (source → PDF)
# ---------------------------------------------------------------------------

LIBREOFFICE_FORMATS = {
    # Modern Office
    ".docx", ".pptx",
    # Legacy Office
    ".doc", ".ppt",
    # OpenDocument
    ".odt", ".odp",
    # Other
    ".html",
}

IMAGE_FORMATS = {".png", ".jpg", ".jpeg", ".tiff", ".tif"}
MARKDOWN_FORMATS = {".md", ".markdown"}

# ---------------------------------------------------------------------------
# Output format sets  (PDF → target)
# ---------------------------------------------------------------------------

LIBREOFFICE_OUTPUT_FORMATS = {
    ".docx", ".odt",          # Word-processor
    ".odp",                   # Presentation
}

IMAGE_OUTPUT_FORMATS = {".png", ".jpg", ".jpeg", ".tiff"}

OUTPUT_FORMATS = {".pdf", ".html"} | LIBREOFFICE_OUTPUT_FORMATS | IMAGE_OUTPUT_FORMATS


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------

def _unique_subdir(output_dir: str) -> str:
    """Create and return a unique subdirectory inside output_dir.

    Prevents filename collisions when multiple sources share the same stem
    (e.g. reports/summary.docx and archive/summary.docx both become summary.pdf).
    """
    subdir = os.path.join(output_dir, uuid.uuid4().hex[:8])
    os.makedirs(subdir)
    return subdir


def _libreoffice_cmd() -> str:
    """Return the LibreOffice executable name for the current platform."""
    # On Windows, LibreOffice ships as 'soffice' / 'soffice.exe'
    return "soffice" if sys.platform == "win32" else "libreoffice"


def _libreoffice_install_hint() -> str:
    """Return a platform-appropriate install instruction."""
    if sys.platform == "win32":
        return (
            "Download and install LibreOffice from https://www.libreoffice.org/download\n"
            "After installing, make sure 'soffice.exe' is added to your PATH,\n"
            "or restart your terminal and try again."
        )
    if sys.platform == "darwin":
        return "Install with: brew install --cask libreoffice"
    return "Install with: sudo apt install libreoffice  (or your distro's equivalent)"


# LibreOffice requires explicit filter names for certain output formats,
# especially when the source is a PDF (which needs the PDF Import extension).
_LO_FILTER = {
    "docx": "docx:MS Word 2007 XML",
    "odt":  "odt:writer8",
    "odp":  "odp:impress8",
    "html": "html:HTML (StarWriter)",
    "pdf":  "pdf:writer_pdf_Export",
}


def _run_libreoffice(src_path: str, out_fmt: str, out_dir: str) -> Path:
    """Run LibreOffice headless conversion and return the output file path.

    Each call gets a unique UserInstallation directory so multiple LibreOffice
    instances can run in parallel without sharing a lock file.
    """
    convert_to = _LO_FILTER.get(out_fmt, out_fmt)
    # Unique LO profile dir — prevents lock conflicts when running in parallel
    lo_profile = Path(out_dir) / ".lo_profile"
    lo_profile.mkdir(exist_ok=True)
    profile_url = lo_profile.as_uri()

    cmd = [_libreoffice_cmd(), f"-env:UserInstallation={profile_url}", "--headless"]
    # When converting from PDF to a Writer-based format, force Writer's PDF
    # import filter so LibreOffice opens the file in Writer (text layout) rather
    # than Draw (graphical).  Without this, DOCX/ODT exports fail with a filter
    # mismatch error.
    if Path(src_path).suffix.lower() == ".pdf" and out_fmt in {
        "docx", "odt", "html", "txt", "rtf"
    }:
        cmd += ["--infilter=writer_pdf_import"]
    cmd += ["--convert-to", convert_to, "--outdir", out_dir, src_path]
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=LO_TIMEOUT,
        )
    except subprocess.TimeoutExpired:
        # Kill any stale LibreOffice processes so they don't block future runs.
        subprocess.run(["pkill", "-f", "soffice"], capture_output=True)
        raise RuntimeError(
            f"LibreOffice timed out converting '{Path(src_path).name}' to {out_fmt} "
            f"(limit: {LO_TIMEOUT}s).\n"
            f"Use --lo-timeout to increase the limit, or split the file into smaller parts first."
        )
    except FileNotFoundError:
        raise RuntimeError(
            f"LibreOffice is not installed or not on PATH.\n"
            f"{_libreoffice_install_hint()}"
        )

    if result.returncode != 0:
        raise RuntimeError(
            f"LibreOffice failed to convert {src_path} to {out_fmt}:\n{result.stderr.strip()}"
        )

    # LibreOffice normally names the output <stem>.<ext>, but when converting
    # from PDF it may use the document's internal title instead of the filename.
    # Check the expected path first, then scan the directory as a fallback.
    expected = Path(out_dir) / (Path(src_path).stem + "." + out_fmt)
    if expected.exists():
        return expected

    matches = list(Path(out_dir).glob(f"*.{out_fmt}"))
    if matches:
        return matches[0]

    raise FileNotFoundError(
        f"Conversion succeeded but output not found: {expected}\n"
        f"LibreOffice stdout: {result.stdout.strip()}\n"
        f"LibreOffice stderr: {result.stderr.strip()}"
    )


# ---------------------------------------------------------------------------
# Input conversions  (source → PDF)
# ---------------------------------------------------------------------------

def libreoffice_to_pdf(src_path: str, output_dir: str) -> str:
    """Convert any LibreOffice-supported document to PDF."""
    subdir = _unique_subdir(output_dir)
    return str(_run_libreoffice(src_path, "pdf", subdir))


def image_to_pdf(image_path: str, output_dir: str) -> str:
    """Wrap a raster image in a single-page PDF using img2pdf (lossless)."""
    subdir = _unique_subdir(output_dir)
    pdf_path = str(Path(subdir) / (Path(image_path).stem + ".pdf"))
    with open(pdf_path, "wb") as f:
        f.write(img2pdf.convert(image_path))
    return pdf_path


def markdown_to_pdf(md_path: str, output_dir: str) -> str:
    """Convert a Markdown file to PDF via HTML → LibreOffice."""
    subdir = _unique_subdir(output_dir)
    md_text = Path(md_path).read_text(encoding="utf-8")
    body = markdown_lib.markdown(md_text, extensions=["tables", "fenced_code"])
    html = (
        "<!DOCTYPE html><html><head>"
        "<meta charset='utf-8'/>"
        "<style>"
        "body{font-family:sans-serif;max-width:800px;margin:40px auto;padding:0 24px;line-height:1.6}"
        "pre{background:#f4f4f4;padding:12px;border-radius:4px;overflow-x:auto}"
        "code{background:#f4f4f4;padding:2px 4px;border-radius:3px}"
        "table{border-collapse:collapse;width:100%}"
        "th,td{border:1px solid #ddd;padding:8px;text-align:left}"
        "th{background:#f0f0f0}"
        "</style>"
        f"</head><body>{body}</body></html>"
    )
    html_path = str(Path(subdir) / (Path(md_path).stem + ".html"))
    Path(html_path).write_text(html, encoding="utf-8")
    return libreoffice_to_pdf(html_path, output_dir)


# ---------------------------------------------------------------------------
# Output conversions  (assembled PDF → target format)
# ---------------------------------------------------------------------------

def pdf_to_format(pdf_path: str, output_path: str) -> str:
    """Convert an assembled PDF to the format indicated by output_path's extension.

    Supported targets:
      - LibreOffice formats (.docx, .pptx, .odt, .odp, .xlsx, .ods, .html)
      - Image formats (.png, .jpg, .jpeg, .tiff)
        Single page  → image file at output_path
        Multi-page   → ZIP of numbered images (output_path with .zip extension)

    Returns the final output path (may differ for multi-page image output).
    Raises RuntimeError if conversion fails.
    """
    suffix = Path(output_path).suffix.lower()
    if suffix == ".html":
        return _pdf_to_html(pdf_path, output_path)
    elif suffix in LIBREOFFICE_OUTPUT_FORMATS:
        return _pdf_to_libreoffice(pdf_path, output_path)
    elif suffix in IMAGE_OUTPUT_FORMATS:
        return _pdf_to_images(pdf_path, output_path)
    else:
        raise ValueError(
            f"Unsupported output format '{suffix}'. "
            f"Supported: {', '.join(sorted(OUTPUT_FORMATS))}"
        )


def _pdf_to_libreoffice(pdf_path: str, output_path: str) -> str:
    """Convert an assembled PDF to a LibreOffice-supported document format."""
    fmt = Path(output_path).suffix.lstrip(".")
    with tempfile.TemporaryDirectory() as tmp:
        converted = _run_libreoffice(pdf_path, fmt, tmp)
        shutil.move(str(converted), output_path)
    return output_path


def _pdf_to_images(pdf_path: str, output_path: str) -> str:
    """Render PDF pages as images using pypdfium2.

    Single page  → saves directly to output_path.
    Multi-page   → saves a ZIP of numbered images; returns the ZIP path.
    """
    suffix = Path(output_path).suffix.lower()
    pil_fmt = "JPEG" if suffix in (".jpg", ".jpeg") else suffix.lstrip(".").upper()

    with _pdfium_lock:
        doc = pdfium.PdfDocument(pdf_path)
        n = len(doc)

        if n == 1:
            bitmap = doc[0].render(scale=2)
            bitmap.to_pil().save(output_path, format=pil_fmt)
            return output_path

        zip_path = str(output_path) + ".zip"
        stem = Path(output_path).stem
        with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
            for i, page in enumerate(doc, 1):
                bitmap = page.render(scale=2)
                buf = io.BytesIO()
                bitmap.to_pil().save(buf, format=pil_fmt)
                zf.writestr(f"{stem}_{i:03d}{suffix}", buf.getvalue())
        return zip_path


def _pdf_to_html(pdf_path: str, output_path: str) -> str:
    """Render each PDF page as a PNG and embed them into a self-contained HTML file.

    Pages are streamed directly to disk one at a time to avoid holding all
    rendered bitmaps in memory simultaneously.
    """
    import base64

    with _pdfium_lock:
        doc = pdfium.PdfDocument(pdf_path)
        title = Path(output_path).stem

        with open(output_path, "w", encoding="utf-8") as f:
            f.write(
                "<!DOCTYPE html><html><head>"
                f'<meta charset="utf-8"/><title>{title}</title>'
                "<style>"
                "body{margin:0;padding:24px;background:#525659;display:flex;flex-direction:column;align-items:center;gap:16px}"
                ".page{box-shadow:0 2px 8px rgba(0,0,0,.5)}"
                ".page img{display:block;max-width:100%;height:auto}"
                "</style></head><body>\n"
            )
            for i, page in enumerate(doc, 1):
                bitmap = page.render(scale=2)
                buf = io.BytesIO()
                bitmap.to_pil().save(buf, format="PNG")
                b64 = base64.b64encode(buf.getvalue()).decode()
                buf.close()
                f.write(
                    f'<div class="page">'
                    f'<img src="data:image/png;base64,{b64}" alt="Page {i}" />'
                    f'</div>\n'
                )
            f.write("</body></html>\n")

    return output_path


