# Copyright (c) 2026 Luigi Paone <ppc.luigi.paone@gmail.com>
# SPDX-License-Identifier: BSD-3-Clause

"""Module with classes for generate reports in various formats.

Main classes in module are Reporter (for LaTeX), MarkdownReporter (for Markdown),
and TypstReporter (for Typst). Reporter uses LaTeX executable for building reports,
MarkdownReporter uses weasyprint for PDF generation, and TypstReporter uses the
typst Python package for PDF generation.

Classes list:

    1. EnumFBSection
    2. Fragment
    3. FragmentsBuilder
    4. getTemplatesPath
    5. getMarkdownTemplatesPath
    6. getTypstTemplatesPath
    7. MarkdownFragment
    8. MarkdownFragmentsBuilder
    9. MarkdownReporter
    10. ReportDriverEnum
    11. Reporter
    12. ReportProperties
    13. ReportTemplateEnum
    14. TypstFragment
    15. TypstFragmentsBuilder
    16. TypstReporter
"""

import os
import subprocess
from enum import Enum, unique
from importlib.resources import files
from pathlib import Path
from typing import Any, Dict, List, Literal, Union
import shutil

from jinja2 import Environment, FileSystemLoader
from pydantic import BaseModel

from pycivil.EXAUtils.EXAExceptions import EXAExceptions
from pycivil.EXAUtils import logging as logger


@unique
class ReportDriverEnum(Enum):
    PDFLATEX = 1
    WEASYPRINT = 2  # Markdown → HTML → PDF
    TYPST = 3  # Typst → PDF


@unique
class ReportTemplateEnum(Enum):
    """This Enum describe the main documents level

    Attributes:
        TEX_ENG_CAL: style for official reports
        TEX_KOMA: styled with KOMA scripts LaTex
        TEX_MAIN: styled with main LaTex
        MD_ENG_CAL: markdown style for official reports
        MD_MAIN: markdown styled with main template
    """
    TEX_ENG_CAL = 1
    TEX_KOMA = 2
    TEX_MAIN = 3
    MD_ENG_CAL = 4
    MD_MAIN = 5
    TYP_ENG_CAL = 6
    TYP_MAIN = 7


def getTemplatesPath() -> Path:
    """Funtion to get templates latex path.

    Returns:
        A Path object.
    """
    return Path(str(files("pycivil") / "templates" / "latex"))


def getMarkdownTemplatesPath() -> Path:
    """Function to get templates markdown path.

    Returns:
        A Path object.
    """
    return Path(str(files("pycivil") / "templates" / "markdown"))


def getTypstTemplatesPath() -> Path:
    """Function to get templates typst path.

    Returns:
        A Path object.
    """
    return Path(str(files("pycivil") / "templates" / "typst"))


class Fragment:
    """Class that hold fragments as list of strings for use with Reporter.

    This class is a container for complete a template (ex. LaTex template).
    Template are processed after with Reporter class. Will be generated
    automatically a type of file. For example PDF or MD etc.

    """
    def __init__(self, latexTemplatePath: Path = Path()):
        self.__fragment: List[str] = []
        self.__latexTemplatePath: Path = latexTemplatePath
        self.__logoFilePath: Path = Path(self.__latexTemplatePath) / Path("logo.png")

    def set_logo(self, logo_path: Path) -> None:
        self.__logoFilePath = logo_path

    def add_line(self, line: str) -> None:
        self.__fragment.append(line)

    def add_lines(self, lines: List[str]) -> None:
        for ll in lines:
            if ll is not None:
                self.__fragment.append(ll)

    def add_template(self, name: str, placeholders: Dict[str, Any]) -> None:
        file_loader = FileSystemLoader(searchpath=self.__latexTemplatePath)
        env = Environment(
            block_start_string=r"\BLOCK{",
            block_end_string="}",
            variable_start_string=r"\VAR{",
            variable_end_string="}",
            comment_start_string=r"\#{",
            comment_end_string="}",
            line_statement_prefix="%-",
            line_comment_prefix="%#",
            trim_blocks=True,
            autoescape=False,
            loader=file_loader,
        )
        jtemplate = env.get_template(name)
        rendered = jtemplate.render(placeholders, logo_path=self.__logoFilePath.as_posix())
        self.__fragment.append(rendered)

    def add(
        self,
        line: Union[str, None] = None,
        lines: Union[List[str], None] = None,
        templateName: Union[str, None] = None,
        templatePlaceholders: Union[Dict[str, Any], None] = None,
    ) -> int:
        if (
            line is not None
            and templateName is None
            and templatePlaceholders is None
            and lines is None
        ):
            self.add_line(line)
        elif (
            line is None
            and lines is None
            and templateName is not None
            and templatePlaceholders is not None
        ):
            self.add_template(templateName, templatePlaceholders)
        elif (
            line is None
            and lines is not None
            and templateName is None
            and templatePlaceholders is None
        ):
            self.add_lines(lines)
        else:
            raise EXAExceptions("0001", "Wrong args", "")
        return len(self.__fragment)

    def frags(self) -> List[str]:
        return self.__fragment


@unique
class EnumFBSection(Enum):
    SEC_CHAPTER = 1
    SEC_SECTION = 2
    SEC_SUBSECTION = 3
    SEC_SUBSUBSECTION = 4


class FragmentsBuilder:
    def __init__(self):
        self.__fragment: Union[Fragment, None] = None
        self.__fragment_options: Dict[str, Any] = {}

    def _setFragment(self, f: Fragment) -> None:
        self.__fragment = f

    def getFragmentOptions(self) -> Dict[str, Any]:
        return self.__fragment_options

    def _setFragmentOptions(self, options: Dict[str, Any]) -> bool:
        self.__fragment_options = options
        return True

    def setFragmentOptions(self, options: Dict[str, Any]) -> bool:
        for name_opt, value_opt in options.items():
            if name_opt not in self.__fragment_options.keys():
                raise ValueError(f"{name_opt} is not a valid option")
        self.__fragment_options = options
        return True

    def buildFragment(self) -> Fragment:
        raise NotImplementedError("Need to be implemented")

    def fragment(self) -> Union[Fragment, None]:
        return self.__fragment


class MarkdownFragment:
    """Class that holds fragments as list of strings for use with MarkdownReporter.

    This class is a container for complete a markdown template.
    Templates are processed using standard Jinja2 delimiters ({{ }}, {% %}).
    """
    def __init__(self, markdownTemplatePath: Path = Path()):
        self.__fragment: List[str] = []
        self.__markdownTemplatePath: Path = markdownTemplatePath
        self.__logoFilePath: Path = Path(self.__markdownTemplatePath) / Path("logo.png")

    def set_logo(self, logo_path: Path) -> None:
        self.__logoFilePath = logo_path

    def add_line(self, line: str) -> None:
        self.__fragment.append(line)

    def add_lines(self, lines: List[str]) -> None:
        for ll in lines:
            if ll is not None:
                self.__fragment.append(ll)

    def add_template(self, name: str, placeholders: Dict[str, Any]) -> None:
        """Add a rendered template using standard Jinja2 delimiters."""
        file_loader = FileSystemLoader(searchpath=self.__markdownTemplatePath)
        env = Environment(
            trim_blocks=True,
            autoescape=False,
            loader=file_loader,
        )
        jtemplate = env.get_template(name)
        rendered = jtemplate.render(placeholders, logo_path=self.__logoFilePath.as_posix())
        self.__fragment.append(rendered)

    def add(
        self,
        line: Union[str, None] = None,
        lines: Union[List[str], None] = None,
        templateName: Union[str, None] = None,
        templatePlaceholders: Union[Dict[str, Any], None] = None,
    ) -> int:
        if (
            line is not None
            and templateName is None
            and templatePlaceholders is None
            and lines is None
        ):
            self.add_line(line)
        elif (
            line is None
            and lines is None
            and templateName is not None
            and templatePlaceholders is not None
        ):
            self.add_template(templateName, templatePlaceholders)
        elif (
            line is None
            and lines is not None
            and templateName is None
            and templatePlaceholders is None
        ):
            self.add_lines(lines)
        else:
            raise EXAExceptions("0001", "Wrong args", "")
        return len(self.__fragment)

    def frags(self) -> List[str]:
        return self.__fragment


class MarkdownFragmentsBuilder:
    """Abstract base class for markdown fragment builders."""
    def __init__(self) -> None:
        self.__fragment: Union[MarkdownFragment, None] = None
        self.__fragment_options: Dict[str, Any] = {}

    def _setFragment(self, f: MarkdownFragment) -> None:
        self.__fragment = f

    def getFragmentOptions(self) -> Dict[str, Any]:
        return self.__fragment_options

    def _setFragmentOptions(self, options: Dict[str, Any]) -> bool:
        self.__fragment_options = options
        return True

    def setFragmentOptions(self, options: Dict[str, Any]) -> bool:
        for name_opt, value_opt in options.items():
            if name_opt not in self.__fragment_options.keys():
                raise ValueError(f"{name_opt} is not a valid option")
        self.__fragment_options = options
        return True

    def buildFragment(self) -> MarkdownFragment:
        raise NotImplementedError("Need to be implemented")

    def fragment(self) -> Union[MarkdownFragment, None]:
        return self.__fragment


class ReportProperties(BaseModel):
    project_brief: str = "Brief"
    module_name: str = "Module Name"
    module_version: str = "Module Version"
    report_designer: str = "Designer user name"
    report_date: str = "18/01/1972"
    report_time: str = "01:30"
    report_token: str = "xxxxxxx"
    report_logo: Path | None = None


class Reporter:
    r"""Class that compiles fragments to produce PDF or others.

    This class compiles fragments to produce PDF or others type of files.
    Actually produces PDF using LaTex files by one of LaTeX implementations as
    MikTex (popular implementation for Windows) or TexLive (popular on Linux).

    Typical usage example:

    ```
     reporter = Reporter(latexTemplatePath)
     reporter.linkFragments(ReportDriverEnum.PDFLATEX, ReportTemplateEnum.TEX_ENG_CAL, [frag_title, frag])
     reporter.makePDF(path=str(working_path), fileName="TEX_ENG_CAL")
    ```
    """
    def __init__(self, latexTemplatePath: Path):
        self.__main_file_name: str = ""
        self.__templated: Union[str, None] = ""
        self.__sepForTex: str = "\n"
        self.__opt_makeGlossary: bool = False
        self.__ll: Literal[0, 1, 2, 3] = 3
        self.__latexTemplatePath: Path = latexTemplatePath
        self.__properties: ReportProperties = ReportProperties()

    def setProperties(self, prop: ReportProperties) -> None:
        self.__properties = prop

    def compileDocument(
        self,
        path: str = "",
        fileName: str = "report",
        verbose: bool = False,
        deleteIfExists: bool = True,
    ) -> None:
        """Build a PDF using driver LATEXPDF

        Write before templated in <path> with name <fileName>.
        Extension are automatically added at the end of file name.

        Args:
            deleteIfExists (bool, optional): if True delete file PDF if exists
            verbose (bool, optional):
            path (str, optional): where intermediate file are crated. Defaults to ''.
            fileName (str, optional): input file name. Defaults to 'report'.
        """
        completeFileName = Path(f"{path}/{fileName}")
        completeFileNameTex = Path(f"{completeFileName}.tex")
        completeFileNamePdf = Path(f"{completeFileName}.pdf")

        # Save tex file
        with open(file=completeFileNameTex, mode="w", encoding="utf-8") as f:
            f.write(self.__templated)  # type: ignore

        if deleteIfExists:
            if os.path.exists(completeFileNamePdf):
                os.remove(completeFileNamePdf)
                logger.log(
                    tp="INF", level=self.__ll, msg=f"removed file {completeFileNamePdf}"
                )
            else:
                logger.log(
                    tp="INF",
                    level=self.__ll,
                    msg=f"file do not exists {completeFileNamePdf}",
                )

        # Compile
        os.chdir(path)
        os.listdir()
        #
        # FIRST RUN PDFLATEX
        #
        print("Setted make glossary:")
        print("... 1/4 run first pdflatex")
        self._run_latex(str(completeFileNameTex), verbose)
        if not self.__opt_makeGlossary:
            print("... 2/4 no run makeglossaries")
        else:
            self._make_glossaries(fileName, path, verbose)

        #
        # SECOND RUN PDFLATEX
        #
        print("... 3/4 run pdflatex for tables width adjust")
        self._run_latex(str(completeFileNameTex), verbose)

        #
        # THIRD RUN PDFLATEX
        #
        print("... 4/4 run pdflatex for tables width adjust")
        if self._run_latex(str(completeFileNameTex), verbose):
            print(f"PDF generated as {completeFileNamePdf}")

    def _make_glossaries(self, fileName, path, verbose):
        #
        # SECOND RUN MAKEGLOSSARIES
        #
        print("... 2/4 run makeglossaries")
        print(f"Current working directory: {os.getcwd()}")
        cwdOld = os.getcwd()
        os.chdir(path)
        print(f"for makeglossaries working directory: {os.getcwd()}")
        self._run_command(["makeglossaries", fileName], verbose)
        os.chdir(cwdOld)

    def _run_latex(self, file_name: str, verbose: bool = False) -> bool:
        logger.log("INF", msg=f"File name pdflatex run is in {file_name}", level=self.__ll)

        return self._run_command(["pdflatex", "--no-shell-escape",file_name], verbose)

    @staticmethod
    def _run_command(args: List[str], verbose: bool = False) -> bool:
        # Check if pdflatex is in PATH

        program_path = shutil.which(args[0])
        if shutil.which(args[0]):
            print(f"{program_path} found in PATH {program_path}")
        else:
            print(f"{program_path} NOT found in PATH - need to install")
        print(args)

        if os.name == "nt":
            x = subprocess.run(
                executable=None,
                args=args,
                text=True,
                shell=True,
                capture_output=not verbose)
        else:
            x = subprocess.run(
                executable=args[0],
                args=args[1:len(args)],
                text=True,
                shell=True,
                capture_output=not verbose)

        if x.returncode != 0:
            print("... Exit-code not 0, check result!")
        else:
            print("... Exit-code 0")
        return x.returncode == 0

    def linkFragments(
        self,
        template: ReportTemplateEnum,
        driver: ReportDriverEnum = ReportDriverEnum.PDFLATEX,
        fragments: List[Fragment] | None = None,
        builder: Union[FragmentsBuilder, List[FragmentsBuilder]] | None = None,
        glossary: bool = False,
        main_file_name: str = "",
    ) -> bool:
        """Links fragments adding also a main documents.

        Links fragments adding also a main documents. For example in LaTex
        the linking process add

        ```LaTex
            \\documentclass[11pt, a4paper]{article}
            \\usepackage[latin1]{inputenc}
            ...
            \\begin{document}
            ...
            Fragment line 1
            Fragment line 2
            ...
            Fragment line n
            ...
            \\end{document}
        ```

        Args:
            template (ReportTemplateEnum):
             Enum for main document template.
            driver (ReportDriverEnum, Optional):
             Leave default option.
            fragments (List[Fragment], Optional):
             List of rows to put in main document.
            builder (Union[FragmentsBuilder, List[FragmentsBuilder]], Optional):
             List of fragment builder. This is alternative to fragments
             argument. Default is None.
            glossary (bool, optional): Default is False.
            main_file_name (str, optional): Default is "".

        Returns:
            True on success, False otherwise
        """
        self.__opt_makeGlossary = glossary
        self.__main_file_name = main_file_name
        print("buildPDF: start ...")

        if builder is not None:
            if not isinstance(builder, list):
                builders = [builder]
            else:
                builders = builder

            fragments = []
            for b in builders:
                fragments.append(b.buildFragment())

        united = []
        assert fragments is not None
        for f in fragments:
            if f.frags() is not None:
                united += f.frags()  # type: ignore
            else:
                print("ERR: None fragment")
                print("buildPDF: Quit")
                return False

        if ReportDriverEnum.PDFLATEX != driver:
            print("ERR: driver unknown !!! quit")
            return False

        file_loader = FileSystemLoader(searchpath=self.__latexTemplatePath)
        env = Environment(
            block_start_string="\\BLOCK{",
            block_end_string="}",
            variable_start_string="\\VAR{",
            variable_end_string="}",
            comment_start_string="\\#{",
            comment_end_string="}",
            line_statement_prefix="%-",
            line_comment_prefix="%#",
            trim_blocks=True,
            autoescape=False,
            loader=file_loader,
        )

        print("build with pdflatex ...")

        # LaTex substitution strings
        #
        # 1) Changing "_" with "\_" for latex
        project_brief = self.__properties.project_brief.replace("_", r"\_")

        if (
            template == ReportTemplateEnum.TEX_ENG_CAL
            or template == ReportTemplateEnum.TEX_MAIN
        ):
            print("... template TEX_ENG_CAL")
            if template == ReportTemplateEnum.TEX_ENG_CAL:
                jtemplate = env.get_template("template-eng-cal.tex")
            else:
                jtemplate = env.get_template(self.__main_file_name)

            logoPath = self.__latexTemplatePath / "logo.png"

            # override logo path
            if self.__properties.report_logo is not None:
                logoPath = self.__properties.report_logo

            self.__templated = jtemplate.render(
                place_holder=self.__sepForTex.join(united),
                glossary=self.__opt_makeGlossary,
                logo_path=logoPath.as_posix(),
                project_brief=project_brief,
                module_name=self.__properties.module_name,
                module_version=self.__properties.module_version,
                report_designer=self.__properties.report_designer,
                report_date=self.__properties.report_date,
                report_time=self.__properties.report_time,
                report_token=self.__properties.report_token,
            )

        elif template == ReportTemplateEnum.TEX_KOMA:
            print("... template TEX_KOMA")
            jtemplate = env.get_template("template-koma.tex")
            self.__templated = jtemplate.render(
                glossary=self.__opt_makeGlossary,
                place_holder=self.__sepForTex.join(united),
            )

        else:
            print("ERR: driver unknown !!! quit")
            return False

        return True


class MarkdownReporter:
    """Class that compiles markdown fragments to produce PDF, MD, or HTML.

    This class compiles markdown fragments using weasyprint to produce PDF files,
    or can output raw markdown and HTML.

    Typical usage example:

    ```
     reporter = MarkdownReporter(markdownTemplatePath)
     reporter.linkFragments(ReportTemplateEnum.MD_ENG_CAL, [frag])
     reporter.compileDocument(path=str(working_path), fileName="report")
    ```
    """
    def __init__(self, markdownTemplatePath: Path):
        self.__main_file_name: str = ""
        self.__templated: Union[str, None] = ""
        self.__sep: str = "\n\n"
        self.__opt_makeGlossary: bool = False
        self.__ll: Literal[0, 1, 2, 3] = 3
        self.__markdownTemplatePath: Path = markdownTemplatePath
        self.__properties: ReportProperties = ReportProperties()

    def setProperties(self, prop: ReportProperties) -> None:
        self.__properties = prop

    def compileDocument(
        self,
        path: str = "",
        fileName: str = "report",
        output_format: Literal["md", "html", "pdf"] = "md",
        deleteIfExists: bool = True,
    ) -> None:
        """Build output file from markdown.

        Write the templated content to <path> with name <fileName>.
        Extensions are automatically added at the end of file name.

        Args:
            path (str, optional): where output files are created. Defaults to ''.
            fileName (str, optional): input file name. Defaults to 'report'.
            output_format (Literal["md", "html", "pdf"], optional):
                Output format. Defaults to 'md'.
            deleteIfExists (bool, optional): if True delete file if exists
        """
        import markdown # type: ignore[import-untyped]

        completeFileName = Path(f"{path}/{fileName}")
        completeFileNameMd = Path(f"{completeFileName}.md")
        completeFileNameHtml = Path(f"{completeFileName}.html")

        # Always save markdown file
        with open(file=completeFileNameMd, mode="w", encoding="utf-8") as f:
            f.write(self.__templated)  # type: ignore
        logger.log(
            tp="INF", level=self.__ll, msg=f"Markdown file saved to {completeFileNameMd}"
        )

        if output_format in ("html", "pdf"):
            # Convert markdown to HTML
            md = markdown.Markdown(extensions=["tables", "fenced_code", "md_in_html"])
            html_content = md.convert(self.__templated)  # type: ignore

            # Load CSS
            css_path = self.__markdownTemplatePath / "default.css"
            css_content = ""
            if css_path.exists():
                with open(css_path, encoding="utf-8") as css_file:
                    css_content = css_file.read()

            # MathJax configuration for browser rendering (HTML output)
            mathjax_script = """
    <script>
        MathJax = {
            tex: {
                inlineMath: [['$', '$']],
                displayMath: [['$$', '$$']]
            },
            svg: {
                fontCache: 'global'
            }
        };
    </script>
    <script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js" async></script>"""

            # Build full HTML document with MathJax for LaTeX rendering
            html_doc = f"""<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>{self.__properties.project_brief}</title>
    <style>
{css_content}
    </style>{mathjax_script}
</head>
<body>
{html_content}
</body>
</html>"""

            if output_format == "html" or output_format == "pdf":
                if deleteIfExists and completeFileNameHtml.exists():
                    completeFileNameHtml.unlink()
                with open(file=completeFileNameHtml, mode="w", encoding="utf-8") as f:
                    f.write(html_doc)
                logger.log(
                    tp="INF", level=self.__ll, msg=f"HTML file saved to {completeFileNameHtml}"
                )

            if output_format == "pdf":
                logger.log(
                    tp="WRN", level=self.__ll,
                    msg="PDF output from markdown is not yet supported. Use HTML output instead."
                )
                print("WRN: PDF output from markdown is not yet supported. Use HTML output instead.")

    def linkFragments(
        self,
        template: ReportTemplateEnum,
        fragments: List[MarkdownFragment] | None = None,
        builder: Union[MarkdownFragmentsBuilder, List[MarkdownFragmentsBuilder]] | None = None,
        glossary: bool = False,
        main_file_name: str = "",
    ) -> bool:
        """Links markdown fragments adding also a main document.

        Args:
            template (ReportTemplateEnum):
             Enum for main document template (MD_ENG_CAL or MD_MAIN).
            fragments (List[MarkdownFragment], Optional):
             List of markdown fragments to put in main document.
            builder (Union[MarkdownFragmentsBuilder, List[MarkdownFragmentsBuilder]], Optional):
             List of fragment builder. This is alternative to fragments
             argument. Default is None.
            glossary (bool, optional): Default is False.
            main_file_name (str, optional): Default is "".

        Returns:
            True on success, False otherwise
        """
        self.__opt_makeGlossary = glossary
        self.__main_file_name = main_file_name
        print("buildMarkdown: start ...")

        if builder is not None:
            if not isinstance(builder, list):
                builders = [builder]
            else:
                builders = builder

            fragments = []
            for b in builders:
                fragments.append(b.buildFragment())

        united: List[str] = []
        assert fragments is not None
        for f in fragments:
            if f.frags() is not None:
                united += f.frags()
            else:
                print("ERR: None fragment")
                print("buildMarkdown: Quit")
                return False

        file_loader = FileSystemLoader(searchpath=self.__markdownTemplatePath)
        env = Environment(
            trim_blocks=True,
            autoescape=False,
            loader=file_loader,
        )

        print("build with markdown ...")

        if (
            template == ReportTemplateEnum.MD_ENG_CAL
            or template == ReportTemplateEnum.MD_MAIN
        ):
            print("... template MD_ENG_CAL")
            if template == ReportTemplateEnum.MD_ENG_CAL:
                jtemplate = env.get_template("template-eng-cal.md")
            else:
                jtemplate = env.get_template(self.__main_file_name)

            logoPath = self.__markdownTemplatePath / "logo.png"

            # override logo path
            if self.__properties.report_logo is not None:
                logoPath = self.__properties.report_logo

            self.__templated = jtemplate.render(
                place_holder=self.__sep.join(united),
                glossary=self.__opt_makeGlossary,
                logo_path=logoPath.as_posix(),
                project_brief=self.__properties.project_brief,
                module_name=self.__properties.module_name,
                module_version=self.__properties.module_version,
                report_designer=self.__properties.report_designer,
                report_date=self.__properties.report_date,
                report_time=self.__properties.report_time,
                report_token=self.__properties.report_token,
                page="1",
            )
        else:
            print("ERR: template unknown !!! quit")
            return False

        return True


class TypstFragment:
    """Class that holds fragments as list of strings for use with TypstReporter.

    This class is a container for complete a typst template.
    Templates use custom Jinja2 delimiters to avoid Typst '#' conflicts:
    block: <%% ... %%>, variable: << ... >>, comment: <#% ... %#>
    """
    def __init__(self, typstTemplatePath: Path = Path()):
        self.__fragment: List[str] = []
        self.__typstTemplatePath: Path = typstTemplatePath
        self.__logoFilePath: Path = Path(self.__typstTemplatePath) / Path("logo.png")

    def set_logo(self, logo_path: Path) -> None:
        self.__logoFilePath = logo_path

    def add_line(self, line: str) -> None:
        self.__fragment.append(line)

    def add_lines(self, lines: List[str]) -> None:
        for ll in lines:
            if ll is not None:
                self.__fragment.append(ll)

    def add_template(self, name: str, placeholders: Dict[str, Any]) -> None:
        """Add a rendered template using custom Jinja2 delimiters for Typst."""
        file_loader = FileSystemLoader(searchpath=self.__typstTemplatePath)
        env = Environment(
            block_start_string="<%%",
            block_end_string="%%>",
            variable_start_string="<<",
            variable_end_string=">>",
            comment_start_string="<#%",
            comment_end_string="%#>",
            trim_blocks=True,
            autoescape=False,
            loader=file_loader,
        )
        jtemplate = env.get_template(name)
        rendered = jtemplate.render(placeholders, logo_path=self.__logoFilePath.as_posix())
        self.__fragment.append(rendered)

    def add(
        self,
        line: Union[str, None] = None,
        lines: Union[List[str], None] = None,
        templateName: Union[str, None] = None,
        templatePlaceholders: Union[Dict[str, Any], None] = None,
    ) -> int:
        if (
            line is not None
            and templateName is None
            and templatePlaceholders is None
            and lines is None
        ):
            self.add_line(line)
        elif (
            line is None
            and lines is None
            and templateName is not None
            and templatePlaceholders is not None
        ):
            self.add_template(templateName, templatePlaceholders)
        elif (
            line is None
            and lines is not None
            and templateName is None
            and templatePlaceholders is None
        ):
            self.add_lines(lines)
        else:
            raise EXAExceptions("0001", "Wrong args", "")
        return len(self.__fragment)

    def frags(self) -> List[str]:
        return self.__fragment


class TypstFragmentsBuilder:
    """Abstract base class for typst fragment builders."""
    def __init__(self) -> None:
        self.__fragment: Union[TypstFragment, None] = None
        self.__fragment_options: Dict[str, Any] = {}

    def _setFragment(self, f: TypstFragment) -> None:
        self.__fragment = f

    def getFragmentOptions(self) -> Dict[str, Any]:
        return self.__fragment_options

    def _setFragmentOptions(self, options: Dict[str, Any]) -> bool:
        self.__fragment_options = options
        return True

    def setFragmentOptions(self, options: Dict[str, Any]) -> bool:
        for name_opt, value_opt in options.items():
            if name_opt not in self.__fragment_options.keys():
                raise ValueError(f"{name_opt} is not a valid option")
        self.__fragment_options = options
        return True

    def buildFragment(self) -> TypstFragment:
        raise NotImplementedError("Need to be implemented")

    def fragment(self) -> Union[TypstFragment, None]:
        return self.__fragment


class TypstReporter:
    """Class that compiles typst fragments to produce PDF or .typ files.

    This class compiles typst fragments using the typst Python package
    to produce PDF files, or can output raw .typ source.

    Typical usage example:

    ```
     reporter = TypstReporter(typstTemplatePath)
     reporter.linkFragments(ReportTemplateEnum.TYP_ENG_CAL, [frag])
     reporter.compileDocument(path=str(working_path), fileName="report")
    ```
    """
    def __init__(self, typstTemplatePath: Path):
        self.__main_file_name: str = ""
        self.__templated: Union[str, None] = ""
        self.__sep: str = "\n\n"
        self.__ll: Literal[0, 1, 2, 3] = 3
        self.__typstTemplatePath: Path = typstTemplatePath
        self.__properties: ReportProperties = ReportProperties()
        self.__logoSourcePath: Path | None = None

    def setProperties(self, prop: ReportProperties) -> None:
        self.__properties = prop

    def compileDocument(
        self,
        path: str = "",
        fileName: str = "report",
        output_format: Literal["typ", "pdf"] = "typ",
        deleteIfExists: bool = True,
    ) -> None:
        """Build output file from typst.

        Write the templated content to <path> with name <fileName>.
        Extensions are automatically added at the end of file name.

        Args:
            path: where output files are created. Defaults to ''.
            fileName: input file name. Defaults to 'report'.
            output_format: Output format ('typ' or 'pdf'). Defaults to 'typ'.
            deleteIfExists: if True delete file if exists
        """
        completeFileName = Path(f"{path}/{fileName}")
        completeFileNameTyp = Path(f"{completeFileName}.typ")
        completeFileNamePdf = Path(f"{completeFileName}.pdf")

        # Copy logo to output directory if available (for .typ portability)
        if self.__logoSourcePath is not None:
            destLogo = Path(path) / self.__logoSourcePath.name
            if destLogo.resolve() != self.__logoSourcePath.resolve():
                shutil.copy2(self.__logoSourcePath, destLogo)

        # Always save typst file
        with open(file=completeFileNameTyp, mode="w", encoding="utf-8") as f:
            f.write(self.__templated)  # type: ignore
        logger.log(
            tp="INF", level=self.__ll, msg=f"Typst file saved to {completeFileNameTyp}"
        )

        if output_format == "pdf":
            import typst  # type: ignore[import-untyped]

            if deleteIfExists and completeFileNamePdf.exists():
                completeFileNamePdf.unlink()

            pdf_bytes = typst.compile(completeFileNameTyp)
            completeFileNamePdf.write_bytes(pdf_bytes)
            logger.log(
                tp="INF", level=self.__ll,
                msg=f"PDF file saved to {completeFileNamePdf}"
            )

    def linkFragments(
        self,
        template: ReportTemplateEnum,
        fragments: List[TypstFragment] | None = None,
        builder: Union[TypstFragmentsBuilder, List[TypstFragmentsBuilder]] | None = None,
        glossary: bool = False,
        main_file_name: str = "",
    ) -> bool:
        """Links typst fragments adding also a main document.

        Args:
            template: Enum for main document template (TYP_ENG_CAL or TYP_MAIN).
            fragments: List of typst fragments to put in main document.
            builder: List of fragment builder. Alternative to fragments. Default is None.
            glossary: Default is False.
            main_file_name: Default is "".

        Returns:
            True on success, False otherwise
        """
        self.__main_file_name = main_file_name
        print("buildTypst: start ...")

        if builder is not None:
            if not isinstance(builder, list):
                builders = [builder]
            else:
                builders = builder

            fragments = []
            for b in builders:
                fragments.append(b.buildFragment())

        united: List[str] = []
        assert fragments is not None
        for f in fragments:
            if f.frags() is not None:
                united += f.frags()
            else:
                print("ERR: None fragment")
                print("buildTypst: Quit")
                return False

        file_loader = FileSystemLoader(searchpath=self.__typstTemplatePath)
        env = Environment(
            block_start_string="<%%",
            block_end_string="%%>",
            variable_start_string="<<",
            variable_end_string=">>",
            comment_start_string="<#%",
            comment_end_string="%#>",
            trim_blocks=True,
            autoescape=False,
            loader=file_loader,
        )

        print("build with typst ...")

        if (
            template == ReportTemplateEnum.TYP_ENG_CAL
            or template == ReportTemplateEnum.TYP_MAIN
        ):
            print("... template TYP_ENG_CAL")
            if template == ReportTemplateEnum.TYP_ENG_CAL:
                jtemplate = env.get_template("template-eng-cal.typ")
            else:
                jtemplate = env.get_template(self.__main_file_name)

            logoPath = self.__typstTemplatePath / "logo.png"

            # override logo path
            if self.__properties.report_logo is not None:
                logoPath = self.__properties.report_logo

            # Resolve to absolute path to make the copy condition robust.
            # Typst resolves images relative to the .typ file, so pass only the filename.
            self.__logoSourcePath = logoPath.resolve() if logoPath.exists() else None
            logoFileName = logoPath.name if self.__logoSourcePath is not None else ""

            def _esc(s: str) -> str:
                """Escape Typst special characters in user strings."""
                return s.replace("\\", "\\\\").replace("@", "\\@").replace("<", "\\<").replace(">", "\\>").replace("#", "\\#")

            self.__templated = jtemplate.render(
                place_holder=self.__sep.join(united),
                glossary=glossary,
                logo_path=logoFileName,
                has_logo=self.__logoSourcePath is not None,
                project_brief=_esc(self.__properties.project_brief),
                module_name=_esc(self.__properties.module_name),
                module_version=_esc(self.__properties.module_version),
                report_designer=_esc(self.__properties.report_designer),
                report_date=self.__properties.report_date,
                report_time=self.__properties.report_time,
                report_token=self.__properties.report_token,
                page="1",
            )
        else:
            print("ERR: template unknown !!! quit")
            return False

        return True
