from datetime import datetime
from datetime import timedelta
from logging import getLogger
from pathlib import Path
from plone import api
from plone.autoform.form import AutoExtensibleForm
from plone.base.utils import safe_bytes
from plone.dexterity.content import Item
from plone.memoize.view import memoize
from plone.protect.interfaces import IDisableCSRFProtection
from plone.supermodel import model
from ploneintranet import api as pi_api
from ploneintranet.asynctasks.browser.views import AbstractAsyncView
from ploneintranet.asynctasks.tasks import GeneratePDF
from ploneintranet.core import _
from ploneintranet.docconv.client.interfaces import IPreviewSettings
from ploneintranet.docconv.client.utils import getDocumentType
from ploneintranet.search.interfaces import ISiteSearch
from ploneintranet.search.solr.browser.maintenance import timer
from pprint import pformat
from Products.Five import BrowserView
from time import process_time
from time import time
from transaction import commit
from z3c.form import form
from zope import schema
from zope.annotation import IAnnotations
from zope.component import getUtility
from zope.interface import alsoProvides
from zope.schema.vocabulary import SimpleTerm
from zope.schema.vocabulary import SimpleVocabulary

logger = getLogger(__name__)


class BaseGeneratePDFView(AbstractAsyncView):
    """Helper View for maintenance tasks"""

    ADDITIONAL_PARAMETERS = [{"name": "purge", "type": "checkbox"}]

    _preview_storage_key = "ploneintranet.docconv.client"

    def clean_previews_for(self, obj):
        """Cleanup the previews for this object
        (useful if we want to purge the previews)
        """
        IAnnotations(obj, {}).pop(self._preview_storage_key, None)

    def generate_pdf_for(self, obj):
        """By default don't do anything"""
        return

    def log_to_response(self, msg):
        try:
            self.request.response.write(safe_bytes(msg))
        except TypeError:
            # XXX happens while testing because self.request.RESPONSE.stdout
            # is a TextIOWrapper (should be BytesIO)
            pass

    @property
    def portal_types(self):
        file_types = api.portal.get_registry_record(
            "ploneintranet.docconv.file_types", default=["File"]
        )
        html_types = api.portal.get_registry_record(
            "ploneintranet.docconv.html_types", default=["Document"]
        )
        return file_types + html_types

    @property
    def filters(self):
        return {
            "portal_type": self.portal_types,
            "path": "/".join(self.context.getPhysicalPath()),
        }

    def iter_objects(self):
        """Get the objects for which we want the pdf to be purged"""
        skip = int(self.request.get("skip", 0))
        if skip:
            self.log_to_response("skipping first %d object(s)...\n" % skip)

        purge = bool(self.request.get("purge"))
        if purge:
            self.log_to_response("forcing PDF regeneration\n")

        search_util = getUtility(ISiteSearch)

        results = search_util.query(filters=self.filters, start=skip, step=999999)

        for result in results:
            obj = result.getObject()
            if purge:
                yield obj
            elif not pi_api.previews.has_previews(obj):
                yield obj
            elif not IPreviewSettings(obj).successfully_converted:
                yield obj
            else:
                self.log_to_response("Skipping %r\n" % obj)

    def generate_pdf(self):
        """Look for objects below this path and generate the PDF"""
        self.log_to_response("generating pdf...\n")
        real = timer()  # real time
        cpu = timer(process_time)  # cpu time

        processed = 0
        for obj in self.iter_objects():
            processed += 1
            self.log_to_response("Triggering PDF generation for %r.\n" % obj)
            self.generate_pdf_for(obj)

        self.log_to_response("PDFs generated.\n")
        msg = "generated PDFs for %d items in %s (%s cpu time)." % (
            processed,
            next(real),
            next(cpu),
        )
        self.log_to_response(msg)
        logger.info(msg)

    def __call__(self):
        """Generare pdf in given context"""
        if self.authenticated():
            return self.generate_pdf()
        else:
            return self.template()


class GenerateSyncPDFView(BaseGeneratePDFView):
    """Helper View for maintenance tasks"""

    def generate_pdf_for(self, obj):
        """Generate the preview for this object and commit"""
        pi_api.previews.generate_pdf(obj)
        commit()


class GeneratePDFAsyncView(BaseGeneratePDFView):
    """Helper View for maintenance tasks"""

    def generate_pdf_for(self, obj):
        """Trigger the asynchronous PDF generation for this object
        (if async PDF generation is enabled)
        """
        generator = GeneratePDF(obj, obj.REQUEST)
        generator(countdown=2)


class GenerateExcelPDFAsyncView(GeneratePDFAsyncView):
    """(re)generate only Excel file previews"""

    portal_types = ["File"]

    def iter_objects(self):
        for obj in super().iter_objects():
            doctype = getDocumentType(obj)
            if doctype is None:
                continue
            if doctype.name == "Excel File":
                yield obj


class GenerateMissingPDFForRecentContent(GeneratePDFAsyncView):
    """Take the recently modified content and check if they have PDFs.
    If not reschedule the PDF generation.

    By default the operation is run for with a modification date >= now - 1h,
    but this can be controlled with the request parameter seconds.

    Example: http://host/view_name?seconds=7200
    """

    @property
    def _timedelta(self):
        try:
            seconds = int(self.request["seconds"])
        except Exception:
            seconds = 3600
        return timedelta(seconds=seconds)

    def authenticated(self):
        alsoProvides(self.request, IDisableCSRFProtection)
        return True

    @property
    def filters(self):
        filters = super().filters
        filters["modified__gt"] = datetime.now() - self._timedelta
        return filters


class PreviewDebug(BrowserView):
    def __call__(self):
        """Return debug information about previews"""
        settings = IPreviewSettings(self.context)
        storage = dict(settings._metadata)
        infos = [
            "Modification date",
            str(self.context.modified()),
            "",
            "Current hash",
            settings.filehash or "",
            "",
            "Content of ploneintranet.docconv.client",
            pformat(storage),
            "",
            "Blob files",
            pformat(dict(storage.get("blob_files", {}))),
        ]
        return "\n".join(infos)


class IPurgeOldPreviewsSchema(model.Schema):
    days = schema.Int(
        title=_("Days"),
        description=_("Purge previews older than this number of days"),
        required=True,
        default=100,
    )

    stat_parameter = schema.Choice(
        title=_("Stat parameter"),
        description=_("The stat parameter to use to get blob time"),
        required=True,
        default="st_atime",
        vocabulary=SimpleVocabulary(
            [
                SimpleTerm(
                    "st_atime",
                    title=_("Access time"),
                ),
                SimpleTerm(
                    "st_mtime",
                    title=_("Modification time"),
                ),
                SimpleTerm(
                    "st_ctime",
                    title=_("Creation time"),
                ),
            ]
        ),
    )

    dry_run = schema.Bool(
        title=_("Dry run"),
        description=_(
            "If checked, don't actually purge the previews, "
            "see the logs to check what would be purged."
        ),
        required=False,
        default=True,
    )


class PurgeOldPreviews(AutoExtensibleForm, form.EditForm):
    """For to purge the old previews"""

    ignoreContext = True
    label = _("Purge old previews")
    description = _(
        "Purge old previews for the contents under the current context. "
        "Running this form is pretty intensive and might result in a timeout. "
        "You might want to execute this form accessing directly the instance."
    )

    schema = IPurgeOldPreviewsSchema
    storage_key = "ploneintranet.docconv.client"

    @property
    @memoize
    def extracted_data(self):
        """Extract the data from the form"""
        return self.extractData()

    @property
    @memoize
    def days(self):
        return self.extracted_data[0]["days"]

    @property
    @memoize
    def threshold(self):
        """Every blob with a time older than this will be purged"""
        return time() - self.days * 24 * 60 * 60

    @property
    @memoize
    def dry_run(self):
        return self.extracted_data[0]["dry_run"]

    @property
    @memoize
    def stat_parameter(self):
        """Decide whether to consider access time, modification time or creation time"""
        return self.extracted_data[0]["stat_parameter"]

    @property
    @memoize
    def results_dict(self):
        return {
            "counter": 0,
            "purged": 0,
            "saved_bytes": 0,
            "start_time": time(),
            "total_bytes": 0,
        }

    def get_blob_access_stat(self, blob):
        """Get the access time of a ZODB.blob.Blob instance

        Inspired by collective.exportimport:

        - https://github.com/collective/collective.exportimport/blob/2a7900f44b24b90b28f740931e1591a14b8f084f/src/collective/exportimport/serializer.py#L68C1-L73C83  # noqa:E501
        """
        connection = blob._p_jar
        connection.setstate(blob)
        db = connection.db()
        blob_path = db.storage.fshelper.layout.getBlobFilePath(
            blob._p_oid, blob._p_serial
        )
        file_obj = Path(db.storage.fshelper.base_dir) / Path(blob_path)
        return file_obj.stat()

    def purge_previews(self, obj, path):
        """Purge the previews for this object"""
        self.results_dict["counter"] += 1
        if not isinstance(obj, Item):
            return

        blob_files = IAnnotations(obj).get(self.storage_key, {}).get("blob_files", {})
        svgs = [filename for filename in blob_files if filename.endswith(".svg")]
        cleaned = False
        for svg in svgs:
            blob = blob_files[svg]
            stat = self.get_blob_access_stat(blob)
            time = getattr(stat, self.stat_parameter)
            self.results_dict["total_bytes"] += stat.st_size
            if time < self.threshold:
                self.results_dict["purged"] += 1
                self.results_dict["saved_bytes"] += stat.st_size
                cleaned = True
                if not self.dry_run:
                    blob_files.pop(svg)
                    if self.results_dict["purged"] % 100 == 0:
                        commit()

            if cleaned:
                if self.dry_run:
                    logger.info("Would purge previews for %r", path)
                else:
                    logger.info("Purged previews for %r", path)

    def purge_old_previews(self):
        """Purge the old previews

        use ZopeFindAndApply to find the objects and purge the previews
        """
        self.context.ZopeFindAndApply(
            self.context,
            search_sub=True,
            apply_func=self.purge_previews,
        )

    def display_results(self):
        """Display the results of the purge run"""
        results = self.results_dict
        elapsed = results["stop_time"] - results["start_time"]
        message = f"Total time: {elapsed:.2f} seconds. "
        message += f"Purged {results['purged']} of {results['counter']} items. "
        message += f"Saved {results['saved_bytes']} bytes of {results['total_bytes']} bytes ({results['saved_bytes'] / results['total_bytes'] * 100}%)."  # noqa:E501
        logger.info(message)
        api.portal.show_message(
            message,
            request=self.request,
        )

    @form.button.buttonAndHandler(_("Purge"))
    def handle_purge(self, action):
        """Purge the old previews"""
        errors = self.extracted_data[1]
        if errors:
            self.status = self.formErrorsMessage
            return
        try:
            self.purge_old_previews()
        except Exception as e:
            logger.exception("Error while purging previews")
            self.status = f"Error: {e}"
        self.results_dict["stop_time"] = time()
        self.display_results()

    @form.button.buttonAndHandler(_("Cancel"))
    def handle_cancel(self, action):
        """Cancel the form"""
        self.request.response.redirect(self.context.absolute_url())
