from bs4 import BeautifulSoup
from BTrees.OOBTree import OOBTree
from DateTime import DateTime
from distutils.spawn import find_executable
from hashlib import md5
from PIL import Image
from plone import api
from plone.base.utils import safe_bytes
from plone.memoize import forever
from ploneintranet.asynctasks.celeryconfig import ASYNC_ENABLED
from ploneintranet.asynctasks.tasks import index_to_solr
from ploneintranet.asynctasks.tasks import ReindexObjectSolr
from ploneintranet.attachments.attachments import AttachmentStorage
from ploneintranet.docconv.client.utils import BaseConverter
from ploneintranet.docconv.client.utils import BaseSubProcess
from ploneintranet.docconv.client.utils import getDocumentType
from ploneintranet.docconv.client.utils import PDF_FILENAME
from ploneintranet.docconv.client.utils import TEXT_REL_PATHNAME
from tempfile import TemporaryDirectory
from time import time
from urllib.parse import urlparse
from weasyprint import CSS
from weasyprint import HTML
from zope.annotation import IAnnotations
from zope.globalrequest import getRequest

import codecs
import getpass
import logging
import os
import shutil
import six
import tempfile
import traceback

logger = logging.getLogger(__name__)

# The INFO/WARNING log levels log way to many messages for our needs.
# We have seen 3k log lines for a single pdf conversion in weasyprint 60.2.
# Limit weasyprint's log level to be less chatty.
# This could also be done through configuration:
# https://docs.python.org/3/library/logging.config.html#configuration-file-format
logging.getLogger("weasyprint").setLevel(logging.ERROR)
logging.getLogger("fontTools").setLevel(logging.WARNING)


# I have reused a lot of code from collective.documentviewer, thank you for
# this excellent work! We are now switching to a much more lightweight
# approach but I still am very much in favor of the c.dv structure.
# Credits: https://github.com/collective/collective.documentviewer


def generate_pdf(obj, event=None):
    """Generate a pdf for a content object.

    This is the main generation hub. It gets called by a browserview -
    sync and async - and handles either html/rich or office documents

    :param obj: The Plone content object to get preview URLs for
    :type obj: A Plone content object
    :param event: The event that triggered the pdf generation
    :return: Does not return anything.
    :rtype: None
    """
    if not getDocumentType(obj):
        logger.info("Object type is not in available file types for conversion.")
        return

    converter = Converter(obj)
    converter()


class DocConvSubProcess(BaseSubProcess):
    """
    Convert a supported document to pdf and extract its text for indexing
    """

    file_cmd = find_executable("file")
    gm_cmd = find_executable("gm")
    libreoffice_cmd = find_executable("libreoffice")
    pdfinfo_cmd = find_executable("pdfinfo")
    pdftotext_cmd = find_executable("pdftotext")

    def dump_text(self, filepath, output_dir):
        output_dir_path = os.path.join(output_dir, TEXT_REL_PATHNAME)
        os.mkdir(output_dir_path)
        output_file_path = os.path.join(output_dir_path, "dump_1.txt")
        cmd = [self.pdftotext_cmd, "-enc", "UTF-8", filepath, output_file_path]
        self._run_command(cmd)

    def get_num_pages(self, filepath):
        cmd = [self.pdfinfo_cmd, filepath]
        pdfinfo = self._run_command(cmd)
        pages_line = [i for i in pdfinfo.splitlines() if i.startswith(b"Pages")][0]
        decoded_line = pages_line.decode("utf-8")
        pages_field = decoded_line.split(":")[1]
        pages_text_value = pages_field.strip()
        return int(pages_text_value)

    def get_mime_type(self, filepath):
        cmd = [self.file_cmd, "-b", "--mime", filepath]
        file_info = self._run_command(cmd).decode("utf-8")
        mime_type = file_info.split(";")[0]
        return mime_type

    @property
    @forever.memoize
    def libreoffice_sysuserconfig(self):
        """Get the SYSUSERCONFIG folder that should be used for LibreOffice"""
        try:
            # If possible use a folder defined through an env variable
            return os.environ["LIBREOFFICE_SYSUSERCONFIG"]
        except KeyError:
            # Fallback to a directory in some tmp folder
            username = getpass.getuser()
            configuration_dir = os.path.join(
                tempfile.gettempdir(), f"{username}_libreoffice_config"
            )
            logger.warning(
                "Using %r as LibreOffice SYSUSERCONFIG. "
                "You might want to set a LIBREOFFICE_SYSUSERCONFIG "
                "environment variable to correct this.",
                configuration_dir,
            )
            return configuration_dir

    def get_libreoffice_env(self, path=None):
        env = os.environ.copy()
        env["SYSUSERCONFIG"] = path or self.libreoffice_sysuserconfig
        return env

    def ensure_libreoffice_is_configured(self):
        """Copy the single sheet rendering config template into the working directory"""
        libreoffice_config_folder = os.path.join(
            self.libreoffice_sysuserconfig, "libreoffice"
        )

        registry = os.path.join(
            libreoffice_config_folder, "user", "registrymodifications.xcu"
        )
        os.makedirs(os.path.dirname(registry), exist_ok=True)
        if os.path.exists(registry):
            return

        logger.info("Initializing libreoffice single sheet rendering configuration.")
        cmd = [
            self.libreoffice_cmd,
            f"-env:UserInstallation=file://{libreoffice_config_folder}",
            "--headless",
            "foo",
        ]
        self._run_command(cmd, env=self.get_libreoffice_env())

        # now change the profile configuration
        with open(registry) as fh:
            lines = fh.readlines()
        singlepagesheet = (
            '<item oor:path="/org.openoffice.Office.Common/Filter/PDF/Export">'
            '<prop oor:name="SinglePageSheets" oor:op="fuse"><value>true</value></prop>'
            "</item>\n"
        )
        lines.insert(2, singlepagesheet)
        with open(registry, "w") as fh:
            fh.write("".join(lines))

    def convert_to_pdf(self, filepath, filename, output_dir):
        # get ext from filename
        ext = os.path.splitext(os.path.normcase(filename))[1][1:]
        inputfilepath = os.path.join(output_dir, "dump.%s" % ext)

        shutil.move(filepath, inputfilepath)
        orig_files = set(os.listdir(output_dir))

        output_pdf_path = os.path.join(output_dir, PDF_FILENAME)
        if ext == "html":
            logger.info("Converting html to pdf using weasyprint.")
            base_url = api.portal.get_registry_record(
                "plone.bundles/ploneintranet.csscompilation",
                default=(
                    "++theme++ploneintranet.theme/generated/assets/quaive/style/all.css"
                ),
            )
            if "://" not in base_url:
                base_url = f"{api.portal.get().absolute_url()}/{base_url}"
            try:
                HTML(inputfilepath, base_url=base_url).write_pdf(
                    output_pdf_path,
                    stylesheets=[CSS(string="td:empty { height: 1.5em }")],
                )
            except Exception:
                logger.exception("Failed to run weasyprint")
        else:
            mime_type = self.get_mime_type(inputfilepath)
            GM_FORMATS = [
                "image/gif",
                "image/jpeg",
                "image/png",
                "image/x-ms-bmp",
                "image/svg+xml",
                "image/tiff",
                "image/x-portable-bitmap",
                "application/postscript",
                "image/x-portable-pixmap",
            ]
            if mime_type in GM_FORMATS:
                logger.info("Converting object to pdf using gm.")
                cmd = [self.gm_cmd, "convert", inputfilepath, output_pdf_path]
            else:
                # configure spreadsheets to render single-page
                self.ensure_libreoffice_is_configured()
                logger.info("Converting object to pdf using libreoffice.")
                # Use env SYSUSERCONFIG working dir also as profile dir

            with TemporaryDirectory() as tmpdir:
                shutil.copytree(
                    self.libreoffice_sysuserconfig, tmpdir, dirs_exist_ok=True
                )
                cmd = [
                    self.libreoffice_cmd,
                    f"-env:UserInstallation=file://{tmpdir}/libreoffice",
                    "--headless",
                    "--invisible",
                    "--norestore",
                    "--nolockcheck",
                    "--convert-to",
                    "pdf",
                    "--outdir",
                    output_dir,
                    inputfilepath,
                ]
                try:
                    self._run_command(cmd, env=self.get_libreoffice_env(path=tmpdir))
                except Exception:
                    logger.exception("Failed to run command: %r", cmd)

        # remove original
        os.remove(inputfilepath)

        # move the file to the right location now
        files = set(os.listdir(output_dir))
        if len(files) != len(orig_files):
            # we should have the same number of files as when we first began
            # since we removed libreoffice.
            # We do this in order to keep track of the files being created
            # and used...
            raise Exception(f"Error converting to pdf: {files} vs {orig_files}")

        converted_path = os.path.join(output_dir, [f for f in files - orig_files][0])
        shutil.move(converted_path, os.path.join(output_dir, PDF_FILENAME))

    def convert(
        self,
        output_dir,
        inputfilepath=None,
        filename="dump.bin",
        requires_conversion=True,
    ):
        filename = os.path.basename(filename)
        ext = os.path.splitext(filename)[1][1:]
        path = os.path.join(output_dir, f"original.{ext}")
        if os.path.exists(path):
            os.remove(path)

        # copy file to be able to work with.
        shutil.copy(inputfilepath, path)

        if requires_conversion:
            self.convert_to_pdf(path, filename, output_dir)
            pdf_path = os.path.join(output_dir, PDF_FILENAME)
        else:
            # File is already a pdf
            pdf_path = path

        try:
            self.dump_text(pdf_path, output_dir)
        except Exception:
            logger.exception("Error extracting text from PDF")

        num_pages = self.get_num_pages(pdf_path)

        # os.remove(path)
        return num_pages


docconv = DocConvSubProcess()
if not docconv.gm_cmd:
    logger.error("No `gm` command in your PATH. Preview generation will not work.")
if not docconv.libreoffice_cmd:
    logger.error(
        "No `libreoffice` command in your PATH. Preview generation will not work."
    )
if not docconv.pdfinfo_cmd:
    logger.error("No `pdfinfo` command in your PATH. Preview generation will not work.")
if not docconv.pdftotext_cmd:
    logger.error(
        "No `pdftotext` command in your PATH. Preview generation will not work."
    )


class Converter(BaseConverter):
    def get_local_image(self, path):
        path, scale_path = path.partition("/@@images/")[::2]
        try:
            img_obj = self.context.restrictedTraverse(path)
        except (KeyError, AttributeError) as e:
            logger.warning(
                "Could not get image object for {}: " "KeyError {}".format(path, e)
            )
            return (None, 0, 0)

        if not img_obj:
            logger.warning("Empty image object: %s" % path)
            return (None, 0, 0)

        if not scale_path:
            if not hasattr(img_obj, "image"):
                # doesn't seem to be a blob image
                logger.warning("Could not get image data: %s" % path)
                return (None, 0, 0)
            namedblobimage = img_obj.image
        elif "/" not in scale_path:
            # We assume scale path
            # is the key of already existent scale + the extension,
            # e.g.: d88fea15-30fc-4b03-9478-72da98dc5c90.png
            try:
                scale_key = scale_path.partition(".")[0]
                plone_scales = IAnnotations(img_obj)["plone.scale"]
                namedblobimage = plone_scales[scale_key]["data"]
            except KeyError:
                logger.warning("Cannot get scale %r for %r", img_obj, scale_path)
                return (None, 0, 0)
        else:
            try:
                field, scale = scale_path.split("/")
                images = img_obj.restrictedTraverse("@@images")
                namedblobimage = images.traverse(field, [scale]).scale.data
            except BaseException:
                logger.warning(f"Cannot scale path {path}, {scale_path}")
                return (None, 0, 0)

        return (namedblobimage.data,) + namedblobimage.getImageSize()

    def convert_virtual_url(self, url):
        request = self.context.REQUEST
        virtual_url_parts = request.get("VIRTUAL_URL_PARTS")
        portal = api.portal.get()
        if not virtual_url_parts:
            portal_url = portal.absolute_url()
            if url.startswith(portal_url):
                server_url = request.get("SERVER_URL")
                return str(url.replace(server_url, ""))
            else:
                return url

        # From the Zope 2.8 change notes
        # (http://old.zope.org/Products/Zope/2.8.0a1/CHANGES.txt/document_view)
        # When a VHM is activated, it adds the mapping 'VIRTUAL_URL_PARTS':
        # (SERVER_URL, BASEPATH1, virtual_url_path) to the request's other
        # dictionary. If BASEPATH1 is empty, it is omitted from the tuple. The
        # joined parts are also added under the key VIRTUAL_URL.
        if len(virtual_url_parts) == 3:
            virtual_host, virtual_root, virtual_path = virtual_url_parts
        else:
            virtual_host, virtual_path = virtual_url_parts
            virtual_root = ""

        if url.startswith(virtual_host):
            path = url[len(virtual_host) :].lstrip("/")[len(virtual_root) :]
            if not path.startswith("/"):
                path = f"/{path}"
            url = f"/{portal.getId()}{path}"

        return url

    def extract_images(self, soup, tempdir):
        index = 0
        for img in soup.find_all("img"):
            if not img.get("src"):
                img["src"] = ""
                continue
            img["src"] = self.convert_virtual_url(img["src"])
            width = 0
            height = 0
            img_data = None
            if img["src"].startswith("//"):
                img["src"] = "http:" + img["src"]
            # local image
            elif img["src"].startswith("/"):
                img_data, width, height = self.get_local_image(img["src"])
            # remote image
            if img["src"].startswith("http"):
                _, remote_server, img_path, _, _, _ = urlparse(img["src"])
                try:
                    conn = six.moves.http_client.HTTPConnection(remote_server)
                    conn.request("GET", img_path)
                    resp = conn.getresponse()
                    if not resp.status == 200:
                        logger.warning(
                            "Could not get image {}: {} {}".format(
                                img["src"], resp.status, resp.reason
                            )
                        )
                    else:
                        img_data = resp.read()
                except Exception as e:
                    logger.warning(
                        "Error getting remote image {}: {}".format(img["src"], e)
                    )
                conn.close()

            if not img_data:
                continue

            img_orig_id = img["src"].split("/")[-1]
            img_ext = img_orig_id.split(".")[-1]
            if img_ext == img_orig_id:
                img_ext = "dat"
            img_id = "image%d.%s" % (index, img_ext)
            index += 1
            img_path = os.path.join(tempdir, img_id)
            with open(img_path, "wb") as img_file:
                img_file.write(img_data)
            if "width" not in img or "height" not in img:
                try:
                    with Image.open(img_file.name) as img_obj:
                        width, height = img_obj.size
                except Exception:
                    logger.exception("Could not get image size for %r", img["src"])
            if width and "width" not in img:
                img["width"] = width
            if height and "height" not in img:
                img["height"] = height
            img["src"] = f"file://{img_path}"
        return soup

    def html_dump(self):
        """Dump the html from the text field of an object along with any
        referenced images to a temporary folder.
        """
        print_preview = api.content.get_view(
            "docconv-print-preview", self.context, getRequest()
        )
        soup = BeautifulSoup(print_preview(), features="lxml")

        output_dir = self.storage_dir
        tempdir = os.path.join(output_dir, "images")
        os.mkdir(tempdir)
        soup = self.extract_images(soup, tempdir)

        path = os.path.join(output_dir, "dump.html")
        with codecs.open(path, "w+", "utf-8") as html_file:
            html_file.write(str(soup))

        return path

    @property
    def doc_type(self):
        return getDocumentType(self.context)

    @property
    def filehash(self):
        """Try to get an hash for the file that has to be converted.
        If it is an html file we have to take care of possible changes in the
        related images
        """
        fallback = str(time() // 60)  # 1 minute cache
        if self.context.portal_type == "Document" or self.doc_type.name != "HTML File":
            if not self.blob:
                return fallback
            with self.blob.open() as f:
                return md5(f.read()).hexdigest()

        text_field = getattr(self.context, "text", None)
        output = getattr(text_field, "output", "")
        if not output:
            return fallback
        # Checking the file content is not enough
        # Theoretically we should check also the related images but that
        # could be really time consuming and unreliable (images can also be
        # external ones), so a 1 minute valid timestamp has is added to the
        # html content
        return md5(
            b"".join((safe_bytes(text_field.output), safe_bytes(fallback)))
        ).hexdigest()

    def run_conversion(self):
        doc_type = self.doc_type

        if doc_type.name == "HTML File":
            # dump the contents of the document and referenced images
            # to the storage dir as dump.html
            inputfilepath = self.html_dump()
        else:
            with self.blob.open() as f:
                inputfilepath = f.name

        filename = self.fw.filename
        return docconv.convert(
            self.storage_dir,
            filename=filename,
            inputfilepath=inputfilepath,
            requires_conversion=self.doc_type.requires_conversion,
        )

    def handle_storage(self):
        storage_dir = self.storage_dir
        settings = self.settings
        context = self.context

        logger.info("Setting blob data for %s" % repr(context))
        # go through temp folder and move items into blob storage
        files = OOBTree()

        if self.doc_type.requires_conversion:
            filepath = os.path.join(storage_dir, PDF_FILENAME)
            files[PDF_FILENAME] = self.saveFileToBlob(filepath)

        # Save the plaintext
        textfilespath = os.path.join(storage_dir, TEXT_REL_PATHNAME)
        for filename in os.listdir(textfilespath):
            filepath = os.path.join(textfilespath, filename)
            filename = f"{TEXT_REL_PATHNAME}/{filename}"
            files[filename] = self.saveFileToBlob(filepath)

        settings.blob_files = files

    def maybe_sync_db(self):
        """Check if in the request we have the explicit request
        to sync the DB before writing to it
        """
        request = getRequest()
        if not request:
            return
        if not request.getHeader("HTTP_X_PLONEINTRANET_DOCCONV_CLIENT_SYNC_DB"):
            return
        try:
            self.context._p_jar.sync()
        except AttributeError:
            # ignore, probably in a unit test
            pass

    def run(self, asynchronous=True):
        request = getRequest()
        if request and not request.get("purge"):
            modification_date = DateTime(self.context.ModificationDate())
            last_converted = DateTime(self.settings.last_updated)
            if last_converted >= modification_date:
                logger.info(
                    "Skipping conversion for %r: "
                    "modification time precedes conversion time",
                    self.context,
                )
                return
            if self.filehash == self.settings.filehash:
                logger.info(
                    "Skipping conversion for %r: "
                    "filehash appears to not have changed",
                    self.context,
                )
                return
        settings = self.settings
        try:
            pages = self.run_conversion()
            # conversion can take a long time.
            # let's sync before we save the changes
            self.maybe_sync_db()
            settings._metadata.clear()
            self.handle_storage()
            settings.num_pages = pages
            settings.successfully_converted = True
        except Exception as ex:
            settings._metadata.clear()
            logger.exception(f"Error converting PDF:\n{ex}%s\n{traceback.format_exc()}")
            settings.successfully_converted = False
            settings.exception_msg = str(ex)
            settings.exception_traceback = traceback.format_exc()

        # Dispatch an async job to reindex the blob into solr only
        if not isinstance(self.context.aq_parent, AttachmentStorage):
            if ASYNC_ENABLED:
                ReindexObjectSolr(self.context, self.context.REQUEST)(countdown=5)
            else:
                index_to_solr(self.context)

        settings.filehash = self.filehash
        settings.last_updated = DateTime().ISO8601()
        settings.converting = False
