# -*- mode: python -*-
# -*- coding: utf-8 -*-

##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet.  All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
#
#   Emmanuel Viennet      emmanuel.viennet@viennet.net
#
##############################################################################

"""Generation de PDF: définitions diverses et gestion du verrou

    reportlab n'est pas réentrante: il ne faut qu'une seule opération PDF au même moment.
    Tout accès à ReportLab doit donc être précédé d'un PDFLOCK.acquire()
    et terminé par un PDFLOCK.release()

    En ScoDoc 9, ce n'est pas nécessaire car on est multiprocessus / monothread.
"""
import datetime
import html
import io
import os
import queue
import re
import threading
import traceback
import unicodedata

import reportlab
from reportlab.pdfgen import canvas
from reportlab.platypus import Paragraph, Frame
from reportlab.platypus.flowables import Flowable
from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate
from reportlab.rl_config import defaultPageSize  # pylint: disable=no-name-in-module
from reportlab.lib.units import inch, cm, mm
from reportlab.lib import styles


from flask import g

from app import log
from app.scodoc.sco_exceptions import ScoGenError, ScoPDFFormatError, ScoValueError
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
from app.scodoc.sco_utils import CONFIG
import sco_version

PAGE_HEIGHT = defaultPageSize[1]
PAGE_WIDTH = defaultPageSize[0]


DEFAULT_PDF_FOOTER_TEMPLATE = CONFIG.DEFAULT_PDF_FOOTER_TEMPLATE


def SU(s: str) -> str:
    "convert s from string to string suitable for ReportLab"
    if not s:
        return ""
    else:
        # Remplace caractères composés
        #  eg 'e\xcc\x81' COMBINING ACUTE ACCENT  par '\xc3\xa9' LATIN SMALL LETTER E WITH ACUTE
        # car les "combining accents" ne sont pas traités par ReportLab mais peuvent
        # nous être envoyés par certains navigateurs ou imports
        # (on en a dans les bases de données)
        s = unicodedata.normalize("NFC", s)
        # Remplace les entités XML/HTML
        # reportlab ne les supporte pas non plus.
        s = html.unescape(s)
        # Remplace les <br> par des <br/>
        s = re.sub(r"<br\s*>", "<br/>", s)
        # And substitute unicode characters not supported by ReportLab
        s = s.replace("‐", "-")
        return s


def get_available_font_names() -> list[str]:
    """List installed font names"""
    can = canvas.Canvas(io.StringIO())
    return can.getAvailableFonts()


def _splitPara(txt):
    "split a string, returns a list of <para > ... </para>"
    L = []
    closetag = "</para>"
    l = len(closetag)
    start = 0
    e = -1
    while 1:
        b = txt.find("<para", start)
        if b < 0:
            if e < 0:
                L.append(txt)  # no para, just return text
            break
        e = txt.find(closetag, b)
        if e < 0:
            raise ValueError("unbalanced para tags")
        L.append(txt[b : e + l])
        start = e
    # fix para: must be followed by a newline (? Reportlab bug turnaround ?)
    L = [re.sub("<para(.*?)>([^\n\r])", "<para\\1>\n\\2", p) for p in L]

    return L


def make_paras(
    txt: str, style, suppress_empty=False, field_name=None
) -> list[Paragraph]:
    """Returns a list of Paragraph instances from a text
    with one or more <para> ... </para>
    """
    result = []
    try:
        paras = _splitPara(txt)
        if suppress_empty:
            r = []
            for para in paras:
                m = re.match(r"\s*<\s*para.*>\s*(.*)\s*<\s*/\s*para\s*>\s*", para)
                if not m:
                    r.append(para)  # not a paragraph, keep it
                else:
                    if m.group(1):  # non empty paragraph
                        r.append(para)
            paras = r
        try:
            result = [Paragraph(SU(s), style) for s in paras]
        except AttributeError as exc:
            raise ScoPDFFormatError("PDF: paragraphe avec balisage invalide") from exc
    except OSError as e:
        msg = str(e)
        # If a file is missing, try to display the invalid name
        m = re.match(r".*\sfilename=\'(.*?)\'.*", msg, re.DOTALL)
        if m:
            filename = os.path.split(m.group(1))[1]
            if filename.startswith("logo_"):
                filename = filename[len("logo_") :]
            raise ScoValueError(
                f"Erreur dans le format PDF paramétré: fichier logo <b>{filename}</b> non trouvé"
            ) from e
        else:
            raise e
    except Exception as exc:
        log(traceback.format_exc())
        log(f"Invalid pdf para format: {txt}")
        try:
            # récupère le nom de la préférence
            if field_name:
                p = sco_preferences.BasePreferences(g.scodoc_dept_id)
                pref = p.prefs_dict.get(field_name)
                if pref:
                    field_name = pref["title"]
            result = [
                Paragraph(
                    SU(
                        f"""<font color="red"><i>Erreur: format invalide (voir préférence {field_name or ""})</i></font>"""
                    ),
                    style,
                )
            ]
            # si on voulait interrompre la génération raise ScoPDFFormatError("Erreur: format invalide")
        except ValueError:  # probleme font ? essaye sans style
            # récupère font en cause ?
            m = re.match(r".*family/bold/italic for (.*)", exc.args[0], re.DOTALL)
            if m:
                message = f"police non disponible: {m[1]}"
            else:
                message = "format invalide"
            scu.flash_once(f"problème génération PDF: {message}")
            return [
                Paragraph(
                    SU(f'<font color="red"><b>Erreur: {message}</b></font>'),
                    reportlab.lib.styles.ParagraphStyle({}),
                )
            ]
    return result


def bold_paras(L, tag="b", close=None):
    """Put each (string) element of L between  <b>
    L is a dict or sequence. (if dict, elements with key begining by _ are unaffected)
    """
    b = "<" + tag + ">"
    if not close:
        close = "</" + tag + ">"
    if hasattr(L, "keys"):
        # L is a dict
        for k in L:
            if k[0] != "_":
                L[k] = b + L[k] or "" + close
        return L
    else:
        # L is a sequence
        return [b + (x or "") + close for x in L]


class BulMarker(Flowable):
    """Custom Flowables pour nos bulletins PDF: invisibles, juste pour se repérer"""

    def wrap(self, *args):
        return (0, 0)

    def draw(self):
        return


class DebutBulletin(BulMarker):
    """Début d'un bulletin.
    Element vide utilisé pour générer les bookmarks
    """

    def __init__(self, bookmark=None, filigranne=None, footer_content=None):
        self.bookmark = bookmark
        self.filigranne = filigranne
        self.footer_content = footer_content
        super().__init__()


class ScoDocPageTemplate(PageTemplate):
    """Our own page template."""

    def __init__(
        self,
        document,
        pagesbookmarks: dict = None,
        author=None,
        title=None,
        subject=None,
        margins=(0, 0, 0, 0),  # additional margins in mm (left,top,right, bottom)
        server_name="",
        footer_template=DEFAULT_PDF_FOOTER_TEMPLATE,
        filigranne=None,
        preferences=None,  # dictionnary with preferences, required
    ):
        """Initialise our page template."""
        # defered import (solve circular dependency ->sco_logo ->scodoc, ->sco_pdf
        from app.scodoc.sco_logos import find_logo

        self.preferences = preferences
        self.pagesbookmarks = pagesbookmarks or {}
        self.pdfmeta_author = author
        self.pdfmeta_title = title
        self.pdfmeta_subject = subject
        self.server_name = server_name
        self.filigranne = filigranne
        self.page_number = 1
        self.footer_template = footer_template
        if self.preferences:
            self.with_page_background = self.preferences["bul_pdf_with_background"]
        else:
            self.with_page_background = False
        self.background_image_filename = None
        # Our doc is made of a single frame
        left, top, right, bottom = [float(x) for x in margins]
        content = Frame(
            10.0 * mm + left * mm,
            13.0 * mm + bottom * mm,
            document.pagesize[0] - 20.0 * mm - left * mm - right * mm,
            document.pagesize[1] - 18.0 * mm - top * mm - bottom * mm,
        )
        super().__init__("ScoDocPageTemplate", [content])
        self.logo = None
        logo = find_logo(
            logoname="bul_pdf_background", dept_id=g.scodoc_dept_id
        ) or find_logo(
            logoname="bul_pdf_background", dept_id=g.scodoc_dept_id, prefix=""
        )
        if logo is None:
            # Also try to use PV background
            logo = find_logo(
                logoname="letter_background", dept_id=g.scodoc_dept_id
            ) or find_logo(
                logoname="letter_background", dept_id=g.scodoc_dept_id, prefix=""
            )
        if logo is not None:
            self.background_image_filename = logo.filepath

    def beforeDrawPage(self, canv, doc):
        """Draws (optional) background, logo and contribution message on each page.

        day   : Day of the month as a decimal number [01,31]
        month : Month as a decimal number [01,12].
        year  : Year without century as a decimal number [00,99].
        Year  : Year with century as a decimal number.
        hour  : Hour (24-hour clock) as a decimal number [00,23].
        minute: Minute as a decimal number [00,59].

        server_url: URL du serveur ScoDoc

        """
        if not self.preferences:
            return
        canv.saveState()
        # ---- Background image
        if self.background_image_filename and self.with_page_background:
            canv.drawImage(
                self.background_image_filename, 0, 0, doc.pagesize[0], doc.pagesize[1]
            )

        # ---- Logo: a small image, positionned at top left of the page
        if self.logo is not None:
            # draws the logo if it exists
            (  # pylint: disable=unpacking-non-sequence
                (width, height),
                image,
            ) = self.logo
            canv.drawImage(image, inch, doc.pagesize[1] - inch, width, height)

        # ---- Add some meta data and bookmarks
        if self.pdfmeta_author:
            canv.setAuthor(SU(self.pdfmeta_author))
        if self.pdfmeta_title:
            canv.setTitle(SU(self.pdfmeta_title))
        if self.pdfmeta_subject:
            canv.setSubject(SU(self.pdfmeta_subject))

        bookmark = self.pagesbookmarks.get(doc.page, None)
        if bookmark:
            canv.bookmarkPage(bookmark)
            canv.addOutlineEntry(SU(bookmark), bookmark)

    def draw_footer(self, canv, content):
        """Print the footer"""
        try:
            canv.setFont(
                self.preferences["SCOLAR_FONT"],
                self.preferences["SCOLAR_FONT_SIZE_FOOT"],
            )
        except KeyError as exc:
            raise ScoValueError(
                f"""Police invalide dans pied de page pdf: {
                self.preferences['SCOLAR_FONT']
                } (taille {self.preferences['SCOLAR_FONT_SIZE_FOOT']})"""
            ) from exc
        canv.drawString(
            self.preferences["pdf_footer_x"] * mm,
            self.preferences["pdf_footer_y"] * mm,
            content,
        )
        canv.restoreState()

    def footer_string(self) -> str:
        """String contenu du pied de page"""
        d = _make_datetime_dict()
        d["scodoc_name"] = sco_version.SCONAME
        d["server_url"] = self.server_name
        return SU(self.footer_template % d)

    def afterDrawPage(self, canv, doc):
        if not self.preferences:
            return
        # ---- Footer
        foot_content = None
        if hasattr(doc, "current_footer"):
            foot_content = doc.current_footer
        self.draw_footer(canv, foot_content or self.footer_string())
        # ---- Filigranne (texte en diagonal en haut a gauche de chaque page)
        filigranne = None
        if hasattr(doc, "filigranne"):
            # filigranne crée par DebutBulletin
            filigranne = doc.filigranne
        if not filigranne and self.filigranne:
            if isinstance(self.filigranne, str):
                filigranne = self.filigranne  # same for all pages
            else:
                filigranne = self.filigranne.get(doc.page, None)
        if filigranne:
            canv.saveState()
            canv.translate(9 * cm, 27.6 * cm)
            canv.rotate(30)
            canv.scale(4.5, 4.5)
            canv.setFillColorRGB(1.0, 0.65, 0.65, alpha=0.6)
            canv.drawRightString(0, 0, SU(filigranne))
            canv.restoreState()
        doc.filigranne = None

    def afterPage(self):
        """Called after all flowables have been drawn on a page.
        Increment pageNum since the page has been completed.
        """
        self.page_number += 1


class BulletinDocTemplate(BaseDocTemplate):
    """Doc template pour les bulletins PDF
    ajoute la gestion des bookmarks
    """

    def __init__(self, *args, **kw):
        super().__init__(*args, **kw)
        self.current_footer = ""
        self.filigranne = None

    # inspired by https://www.reportlab.com/snippets/13/
    def afterFlowable(self, flowable):
        """Called by Reportlab after each flowable"""
        if isinstance(flowable, DebutBulletin):
            self.current_footer = ""
            if flowable.bookmark:
                self.current_footer = flowable.footer_content
                self.canv.bookmarkPage(flowable.bookmark)
                self.canv.addOutlineEntry(
                    SU(flowable.bookmark), flowable.bookmark, level=0, closed=None
                )
            if flowable.filigranne:
                self.filigranne = flowable.filigranne


def _make_datetime_dict() -> dict:
    "a dict with date elements for templates"
    now = datetime.datetime.now()
    return {
        "day": now.day,
        "month": now.month,
        "year": now.year,
        "Year": now.year,
        "hour": now.hour,
        "minute": now.minute,
    }


def pdf_basic_page(
    objects,
    title="",
    preferences=None,
):  # used by gen_table.make_page()
    """Simple convenience fonction: build a page from a list of platypus objects,
    adding a title if specified.
    """
    StyleSheet = styles.getSampleStyleSheet()
    report = io.BytesIO()  # in-memory document, no disk file
    document = BaseDocTemplate(report)
    document.addPageTemplates(
        ScoDocPageTemplate(
            document,
            title=title,
            author="%s %s (E. Viennet)" % (sco_version.SCONAME, sco_version.SCOVERSION),
            footer_template="Edité par %(scodoc_name)s le %(day)s/%(month)s/%(year)s à %(hour)sh%(minute)s",
            preferences=preferences,
        )
    )
    if title:
        head = Paragraph(SU(title), StyleSheet["Heading3"])
        objects = [head] + objects
    document.build(objects)
    data = report.getvalue()
    return data


# Gestion du lock pdf


class PDFLock(object):
    def __init__(self, timeout=15):
        self.Q = queue.Queue(1)
        self.timeout = timeout
        self.current_thread = None
        self.nref = 0

    def release(self):
        "Release lock. Raise Empty if not acquired first"
        if self.current_thread == threading.get_ident():
            self.nref -= 1
            if self.nref == 0:
                log("PDFLock: release from %s" % self.current_thread)
                self.current_thread = None
                self.Q.get(False)
            return
        else:
            self.Q.get(False)

    def acquire(self):
        "Acquire lock. Raise ScoGenError if can't lock after timeout."
        if self.current_thread == threading.get_ident():
            self.nref += 1
            return  # deja lock pour ce thread
        try:
            self.Q.put(1, True, self.timeout)
        except queue.Full as e:
            raise ScoGenError(msg="Traitement PDF occupé: ré-essayez") from e
        self.current_thread = threading.get_ident()
        self.nref = 1
        log("PDFLock: granted to %s" % self.current_thread)


class WatchLock:
    "Surveille threads (mais ne verrouille pas)"

    def __init__(self, timeout=None):
        self.timeout = timeout
        t = threading.current_thread()
        self.native_id = t.native_id
        self.ident = t.ident

    def acquire(self):
        t = threading.current_thread()
        if (self.native_id != t.native_id) or (self.ident != t.ident):
            log(
                f"Warning: LOCK detected several threads ! (native_id {self.native_id} -> {t.native_id}, ident {self.ident} -> {t.ident}"
            )
        self.native_id = t.native_id
        self.ident = t.ident

    def release(self):
        t = threading.current_thread()
        assert (self.native_id == t.native_id) and (self.ident == t.ident)


class FakeLock:
    "Pour ScoDoc 9: pas de verrou"

    def __init__(self, timeout=None):
        pass

    def acquire(self):
        pass

    def release(self):
        pass


PDFLOCK = FakeLock()
