from Acquisition import aq_parent
from base64 import encodebytes
from distutils.spawn import find_executable
from fnmatch import fnmatch
from functools import partial
from io import BytesIO
from logging import getLogger
from PIL import Image
from plone import api
from plone.base.utils import safe_text
from plone.memoize.view import memoize
from plone.protect.interfaces import IDisableCSRFProtection
from ploneintranet import api as pi_api
from ploneintranet.attachments.attachments import AttachmentStorage
from ploneintranet.docconv.client.interfaces import IPreviewSettings
from ploneintranet.docconv.client.utils import BaseConverter
from ploneintranet.docconv.client.utils import BaseSubProcess
from ploneintranet.docconv.client.utils import getDocumentType
from Products.Five import BrowserView
from zExceptions import NotFound
from zope.datetime import rfc1123_date
from zope.interface import alsoProvides

import os
import traceback

logger = getLogger(__name__)


def preferred_basenames(size="small"):
    """Preferred preview source *if* that source is available.

    Note that if original_svg is small enough, no pngs will be available;
    if original_png is not wide, other pngs will not be available, etc.
    """
    if size == "gallery":
        return ("original_svg", "original_png", "1200_png")
    return ("300_png", "1200_png", "original_svg", "original_png")


def get_svg_page(basename, page):
    return f"{basename}_{page}.svg"


def get_svg_path(directory, basename, page):
    return os.path.join(directory, get_svg_page(basename, page))


class SVGRender(BrowserView):
    @property
    @memoize
    def page(self):
        return int(self.request.get("page", 1))

    @property
    @memoize
    def size(self):
        return self.request.get("size", "small")

    @property
    @memoize
    def blob_files(self):
        settings = IPreviewSettings(self.context)
        return getattr(settings, "blob_files", {}) or {}

    def get_svg(self):
        """Return the best preview variant for the requested size.

        This works in tandem with the converter, which has already
        selected and saved the best variant per size.
        """
        for basename in preferred_basenames(self.size):
            svg_page = get_svg_page(basename, self.page)
            if svg_page in self.blob_files:
                return self.blob_files[svg_page]

        # backcompatibility for older previews
        if f"dump_{self.page}.svg" in self.blob_files:
            return self.blob_files[f"dump_{self.page}.svg"]

    def get_blob_and_content_type(self):
        svg = self.get_svg()
        if svg is None and pi_api.previews.has_pdf(self.context):
            alsoProvides(self.request, IDisableCSRFProtection)
            Converter(self.context)(self.page)
            svg = self.get_svg()

        if svg:
            return svg, "image/svg+xml"

        # Old style preview images
        old_img = "dump_%s.gif" % self.page
        return self.blob_files.get(old_img), "image/gif"

    def __call__(self):
        settings = IPreviewSettings(self.context)
        if self.page > (settings.num_pages or 0):
            self.request.response.setBody(
                "<h1>400 Bad Request</h1>"
                "<p>Page not available. This document has only %s pages</p>"
                % settings.num_pages,
                lock=True,
            )
            return self.request.response.setStatus(400)

        blob, content_type = self.get_blob_and_content_type()
        if not blob:
            raise NotFound("SVG preview not available")

        with blob.open() as fh:
            data = fh.read()

        length = len(data)
        self.request.response.setHeader(
            "Last-Modified", rfc1123_date(self.context._p_mtime)
        )
        self.request.response.setHeader("Accept-Ranges", "bytes")
        self.request.response.setHeader("Content-Length", length)
        self.request.response.setHeader("Content-Type", content_type)

        return data


class PDF2SVGSubProcess(BaseSubProcess):
    """
    Convert a pdf to an svg page by page
    """

    pdfinfo_path = find_executable("pdfinfo")
    pdftocairo_path = find_executable("pdftocairo")

    _svg_template = """
    <?xml version="1.0" standalone="no"?>
    <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
    "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
    <svg width="{width}"
        height="{height}"
        version="1.1"
        xmlns="http://www.w3.org/2000/svg"
        xmlns:xlink="http://www.w3.org/1999/xlink">
        <image xlink:href="data:image/png;base64,{b64}"
            x="0" y="0" height="{height}px" width="{width}px"/>
    </svg>
    """.strip()

    @memoize
    def pdfinfo(self, input_path):
        try:
            output = safe_text(self._run_command([self.pdfinfo_path, input_path]))
        except Exception:
            logger.exception("Error calling pdfinfo: %r", output)
            return {}
        info = {}
        for line in output.splitlines():
            key, value = line.partition(":")[::2]
            info[key.strip()] = value.strip()
        return info

    def is_bad_pdf(self, input_path):
        """We know that pdf2 cairo svg conversion has some issues,
        e.g. https://gitlab.freedesktop.org/poppler/poppler/issues/226

        To overcome this we convert the file to the png format.
        """
        bad_creators = api.portal.get_registry_record(
            "ploneintranet.docconv.bad_pdf_creators", default=[]
        )
        if not bad_creators:
            return False

        checks = [
            partial(fnmatch, pat=bad_creator.lower()) for bad_creator in bad_creators
        ]

        pdfinfo = self.pdfinfo(input_path)

        # Look for lines like:
        # - Creator:        Fancy PDF Creator
        # - Producer:       Fancy PDF Creator

        for key in ("Creator", "Producer"):
            value = pdfinfo.get(key)
            if isinstance(value, str):
                value = value.lower()
                for check in checks:
                    if check(value):
                        logger.info("Bad creator found, %r=%r", key, pdfinfo.get(key))
                        return True
        return False

    def pdf_page_size_in_px(self, input_path):
        # Get the Page size in pts
        pdfinfo = self.pdfinfo(input_path)
        page_size = pdfinfo.get("Page size")
        if page_size:
            # example page_size: '595.276 x 841.861 pts (A4)'
            x_pts, _, y_pts = page_size.split(" ")[0:3]
            x_px = float(x_pts) * (72 / 96)
            y_px = float(y_pts) * (72 / 96)
            return x_px, y_px
        return 0, 0

    def convert(self, output_dir, input_path, page=1):
        """Create various preview variants for the page requested.

        This creates up to four variants:
        - original svg: original_svg_1.svg
        - original png wrapped into svg: original_png_1.svg
        - 1200 px png wrapped into svg: 1200_png_1.svg
        - 300px png wrapped into svg: 300_png_1.svg

        It's then up to the storage handler to select which ones to actually store,
        based on comparing file sizes.

        Note that original_png_1.png is a temp file that is never permanently stored.
        """
        # probably called by a computer scientist who starts counting at 0
        # Let's help him into the physical world of pages.
        page = str(page)

        # execute pdf to cairo only on this page
        # pdftocairo input_path -f N -l N ...
        cmd = [self.pdftocairo_path, input_path, "-f", page, "-l", page]

        svg_cmd = cmd + ["-svg", get_svg_path(output_dir, "original_svg", page)]
        if self.is_bad_pdf(input_path):
            logger.info("Bad PDF, not extracting svg for %s", input_path)
        else:
            self._run_command(svg_cmd)

        original_png_cairo = os.path.join(output_dir, f"original_png_{page}")
        max_png_preview_size = api.portal.get_registry_record(
            "ploneintranet.docconv.max_png_preview_size", default=4000
        )
        scale_cmd = []
        x, y = self.pdf_page_size_in_px(input_path)
        if x > max_png_preview_size or y > max_png_preview_size:
            scale_cmd = ["-scale-to", str(max_png_preview_size)]
        png_cmd = (
            cmd
            + scale_cmd
            + [
                "-png",
                "-singlefile",
                original_png_cairo,
            ]
        )
        self._run_command(png_cmd)
        original_png_path = os.path.join(output_dir, f"original_png_{page}.png")
        with Image.open(original_png_path) as original_png_image:
            original_width, original_height = original_png_image.size
            self.save_png_as_svg(
                get_svg_path(output_dir, "original_png", page),
                original_png_image,
                original_width,
                original_height,
            )
        for new_width in (1200, 300):
            if original_width <= new_width:
                logger.info(
                    "Not scaling down to %ipx: %s already %ipx wide",
                    new_width,
                    original_png_path,
                    original_width,
                )
                continue
            new_height = int(round(float(new_width) * original_height / original_width))
            scaled_png_image = original_png_image.resize(
                (new_width, new_height), resample=Image.BICUBIC
            )
            self.save_png_as_svg(
                get_svg_path(output_dir, f"{new_width}_png", page),
                scaled_png_image,
                new_width,
                new_height,
            )

    def save_png_as_svg(self, svg_path, png_image, width, height):
        buffer = BytesIO()
        png_image.save(buffer, format="PNG")
        b64 = encodebytes(buffer.getvalue())
        logger.info("Saving png as svg: %s", svg_path)
        with open(svg_path, "w") as f:
            f.write(
                self._svg_template.format(
                    b64=safe_text(b64), height=height, width=width
                )
            )


pdf2svg = PDF2SVGSubProcess()
if not pdf2svg.pdftocairo_path:
    logger.error(
        "No `pdftocairo` (from `poppler-utils`) command in your PATH. "
        "Preview generation will not work."
    )
if not pdf2svg.pdfinfo_path:
    logger.error(
        "No `pdfinfo` (from `poppler-utils`) command in your PATH. "
        "Preview generation will not work."
    )


class Converter(BaseConverter):
    """
    Convert svg previews on the fly, one page at a time
    """

    @property
    @memoize
    def _preview_threshold_gallery(self):
        """
        Try to keep the gallery preview below this filesize.

        PDF to SVG conversion is less than stellar.
        https://stackoverflow.com/questions/37392798/pdf-to-svg-is-not-perfect

        This can result in 300MB pages. We avoid that by enforcing an upper
        limit on preview page file size. Following #4511, Excel files are
        rendered as a very few very lage pages (one per tab). For Excel we
        therefore use a higher per-page size limit.
        """
        doctype = getDocumentType(self.context)
        if doctype is not None and doctype.name == "Excel File":
            return 10 * 1024 * 1024  # 10 MB
        return 1024 * 1024  # 1 MB

    @property
    @memoize
    def _preview_threshold_small(self):
        """Try to keep the small preview below this filesize"""
        return 128 * 1024  # 128 kB

    def run_conversion(self, page):
        if self.fw.file_type == "application/pdf":
            # context is already a pdf, no need to access a generated version
            blob = self.fw.blob
        else:
            # context is not pdf, so access the generated pdf preview
            blob = pi_api.previews.get_pdf(self.context)
        if not blob:
            logger.info("Cannot find pdf for %r", self.context)
            return
        with blob.open() as f:
            input_path = f.name

        pdf2svg.convert(self.storage_dir, input_path, page)

    def handle_storage(self, page):
        """Select and store only the best preview for (gallery, small)
        from all the preview variants generated by pdf2svg."""
        storage_dir = self.storage_dir
        settings = self.settings
        context = self.context
        parent = aq_parent(context)
        if isinstance(parent, AttachmentStorage):
            logger.info(
                "Setting svg previews %r for attachment %r in %r",
                page,
                context.getId(),
                parent,
            )
        else:
            logger.info("Setting svg previews %r for %r", page, context)

        # index generated previews by size
        files = settings.blob_files
        size = {}
        for basename in ("300_png", "1200_png", "original_svg", "original_png"):
            try:
                size[basename] = os.path.getsize(
                    get_svg_path(storage_dir, basename, page)
                )
            except FileNotFoundError:
                pass
        sorted_basenames = [x[0] for x in sorted(size.items(), key=lambda x: x[1])]

        # derive chosen variant for "gallery"
        gallery_name = None
        for basename in preferred_basenames("gallery"):
            if basename in size and size[basename] < self._preview_threshold_gallery:
                gallery_name = basename  # select preferred variant if small enough
                break
        # edge case: they are all too big, pick the smallest one that is not 300_png
        if not gallery_name:
            # original_png is always available, often also original_svg and 1200_png
            gallery_name = [x for x in sorted_basenames if x != "300_png"][0]

        # derive chosen variant for "small"
        small_name = sorted_basenames[0]  # default to smallest filesize
        if (
            "original_svg" in size
            and size["original_svg"] < self._preview_threshold_small
        ):
            small_name = "original_svg"  # prefer original svg if small enough

        # Store only the chosen variant(s)
        logger.info(
            "Storing %s as gallery preview %i (%i kB)",
            gallery_name,
            page,
            size[gallery_name] / 1024,
        )
        files[get_svg_page(gallery_name, page)] = self.saveFileToBlob(
            get_svg_path(storage_dir, gallery_name, page)
        )
        # we use original_svg everywhere if its filesize is small enough
        if small_name != gallery_name:
            logger.info(
                "Storing %s as small preview %i (%i kB)",
                small_name,
                page,
                size[small_name] / 1024,
            )
            files[get_svg_page(small_name, page)] = self.saveFileToBlob(
                get_svg_path(storage_dir, small_name, page)
            )
        else:
            logger.info("Using %s also for small preview %i", small_name, page)
        # this results in maximally two variants being saved, typically
        # original_svg (for the gallery) and 300_png (for the small preview)
        settings.blob_files = files

    def run(self, page):
        try:
            self.run_conversion(page)
            self.handle_storage(page)
        except Exception as ex:
            logger.exception(
                "Error converting SVG page %s:\n%s\n%s",
                page,
                ex,
                traceback.format_exc(),
            )
            self.settings.exception_msg = getattr(ex, "message", "")
            self.settings.exception_traceback = traceback.format_exc()
