"""Interface for writing, compiling, and converting ``.tex`` files.

.. SEEALSO::

    :mod:`.mobject.svg.tex_mobject`

"""

from __future__ import annotations

import hashlib
import re
import subprocess
import unicodedata
from collections.abc import Generator, Iterable, Sequence
from pathlib import Path
from re import Match
from typing import Any

from manim.utils.tex import TexTemplate

from .. import config, logger

__all__ = ["tex_to_svg_file"]


def tex_hash(expression: Any) -> str:
    id_str = str(expression)
    hasher = hashlib.sha256()
    hasher.update(id_str.encode())
    # Truncating at 16 bytes for cleanliness
    return hasher.hexdigest()[:16]


def tex_to_svg_file(
    expression: str,
    environment: str | None = None,
    tex_template: TexTemplate | None = None,
) -> Path:
    r"""Takes a tex expression and returns the svg version of the compiled tex

    Parameters
    ----------
    expression
        String containing the TeX expression to be rendered, e.g. ``\\sqrt{2}`` or ``foo``
    environment
        The string containing the environment in which the expression should be typeset, e.g. ``align*``
    tex_template
        Template class used to typesetting. If not set, use default template set via `config["tex_template"]`

    Returns
    -------
    :class:`Path`
        Path to generated SVG file.
    """
    if tex_template is None:
        tex_template = config["tex_template"]
    tex_file = generate_tex_file(expression, environment, tex_template)

    # check if svg already exists
    svg_file = tex_file.with_suffix(".svg")
    if svg_file.exists():
        return svg_file

    dvi_file = compile_tex(
        tex_file,
        tex_template.tex_compiler,
        tex_template.output_format,
    )
    svg_file = convert_to_svg(dvi_file, tex_template.output_format)
    if not config["no_latex_cleanup"]:
        delete_nonsvg_files()
    return svg_file


def generate_tex_file(
    expression: str,
    environment: str | None = None,
    tex_template: TexTemplate | None = None,
) -> Path:
    r"""Takes a tex expression (and an optional tex environment),
    and returns a fully formed tex file ready for compilation.

    Parameters
    ----------
    expression
        String containing the TeX expression to be rendered, e.g. ``\\sqrt{2}`` or ``foo``
    environment
        The string containing the environment in which the expression should be typeset, e.g. ``align*``
    tex_template
        Template class used to typesetting. If not set, use default template set via `config["tex_template"]`

    Returns
    -------
    :class:`Path`
        Path to generated TeX file
    """
    if tex_template is None:
        tex_template = config["tex_template"]
    if environment is not None:
        output = tex_template.get_texcode_for_expression_in_env(expression, environment)
    else:
        output = tex_template.get_texcode_for_expression(expression)

    tex_dir = config.get_dir("tex_dir")
    tex_dir.mkdir(parents=True, exist_ok=True)

    result = tex_dir / (tex_hash(output) + ".tex")
    if not result.exists():
        logger.info(
            "Writing %(expression)s to %(path)s",
            {"expression": expression, "path": f"{result}"},
        )
        result.write_text(output, encoding="utf-8")
    return result


def make_tex_compilation_command(
    tex_compiler: str, output_format: str, tex_file: Path, tex_dir: Path
) -> list[str]:
    """Prepares the TeX compilation command, i.e. the TeX compiler name
    and all necessary CLI flags.

    Parameters
    ----------
    tex_compiler
        String containing the compiler to be used, e.g. ``pdflatex`` or ``lualatex``
    output_format
        String containing the output format generated by the compiler, e.g. ``.dvi`` or ``.pdf``
    tex_file
        File name of TeX file to be typeset.
    tex_dir
        Path to the directory where compiler output will be stored.

    Returns
    -------
    :class:`list[str]`
        Compilation command according to given parameters
    """
    if tex_compiler in {"latex", "pdflatex", "luatex", "lualatex"}:
        command = [
            tex_compiler,
            "-interaction=batchmode",
            f"-output-format={output_format[1:]}",
            "-halt-on-error",
            f"-output-directory={tex_dir.as_posix()}",
            f"{tex_file.as_posix()}",
        ]
    elif tex_compiler == "xelatex":
        if output_format == ".xdv":
            outflag = ["-no-pdf"]
        elif output_format == ".pdf":
            outflag = []
        else:
            raise ValueError("xelatex output is either pdf or xdv")
        command = [
            "xelatex",
            *outflag,
            "-interaction=batchmode",
            "-halt-on-error",
            f"-output-directory={tex_dir.as_posix()}",
            f"{tex_file.as_posix()}",
        ]
    else:
        raise ValueError(f"Tex compiler {tex_compiler} unknown.")
    return command


def insight_inputenc_error(matching: Match[str]) -> Generator[str]:
    code_point = chr(int(matching[1], 16))
    name = unicodedata.name(code_point)
    yield f"TexTemplate does not support character '{name}' (U+{matching[1]})."
    yield "See the documentation for manim.mobject.svg.tex_mobject for details on using a custom TexTemplate."


def insight_package_not_found_error(matching: Match[str]) -> Generator[str]:
    yield f"You do not have package {matching[1]} installed."
    yield f"Install {matching[1]} it using your LaTeX package manager, or check for typos."


def compile_tex(tex_file: Path, tex_compiler: str, output_format: str) -> Path:
    """Compiles a tex_file into a .dvi or a .xdv or a .pdf

    Parameters
    ----------
    tex_file
        File name of TeX file to be typeset.
    tex_compiler
        String containing the compiler to be used, e.g. ``pdflatex`` or ``lualatex``
    output_format
        String containing the output format generated by the compiler, e.g. ``.dvi`` or ``.pdf``

    Returns
    -------
    :class:`Path`
        Path to generated output file in desired format (DVI, XDV or PDF).
    """
    result = tex_file.with_suffix(output_format)
    tex_dir = config.get_dir("tex_dir")
    if not result.exists():
        command = make_tex_compilation_command(
            tex_compiler,
            output_format,
            tex_file,
            tex_dir,
        )
        cp = subprocess.run(command, stdout=subprocess.DEVNULL)
        if cp.returncode != 0:
            log_file = tex_file.with_suffix(".log")
            print_all_tex_errors(log_file, tex_compiler, tex_file)
            raise ValueError(
                f"{tex_compiler} error converting to"
                f" {output_format[1:]}. See log output above or"
                f" the log file: {log_file}",
            )
    return result


def convert_to_svg(dvi_file: Path, extension: str, page: int = 1) -> Path:
    """Converts a .dvi, .xdv, or .pdf file into an svg using dvisvgm.

    Parameters
    ----------
    dvi_file
        File name of the input file to be converted.
    extension
        String containing the file extension and thus indicating the file type, e.g. ``.dvi`` or ``.pdf``
    page
        Page to be converted if input file is multi-page.

    Returns
    -------
    :class:`Path`
        Path to generated SVG file.
    """
    result = dvi_file.with_suffix(".svg")
    if not result.exists():
        command = [
            "dvisvgm",
            *(["--pdf"] if extension == ".pdf" else []),
            f"--page={page}",
            "--no-fonts",
            "--verbosity=0",
            f"--output={result.as_posix()}",
            f"{dvi_file.as_posix()}",
        ]
        subprocess.run(command, stdout=subprocess.DEVNULL)

    # if the file does not exist now, this means conversion failed
    if not result.exists():
        raise ValueError(
            f"Your installation does not support converting {dvi_file.suffix} files to SVG."
            f" Consider updating dvisvgm to at least version 2.4."
            f" If this does not solve the problem, please refer to our troubleshooting guide at:"
            f" https://docs.manim.community/en/stable/faq/general.html#my-installation-"
            f"does-not-support-converting-pdf-to-svg-help",
        )

    return result


def delete_nonsvg_files(additional_endings: Iterable[str] = ()) -> None:
    """Deletes every file that does not have a suffix in ``(".svg", ".tex", *additional_endings)``

    Parameters
    ----------
    additional_endings
        Additional endings to whitelist
    """
    tex_dir = config.get_dir("tex_dir")
    file_suffix_whitelist = {".svg", ".tex", *additional_endings}

    for f in tex_dir.iterdir():
        if f.suffix not in file_suffix_whitelist:
            f.unlink()


def print_all_tex_errors(log_file: Path, tex_compiler: str, tex_file: Path) -> None:
    if not log_file.exists():
        raise RuntimeError(
            f"{tex_compiler} failed but did not produce a log file. "
            "Check your LaTeX installation.",
        )
    with log_file.open(encoding="utf-8") as f:
        tex_compilation_log = f.readlines()
    error_indices = [
        index for index, line in enumerate(tex_compilation_log) if line.startswith("!")
    ]
    if error_indices:
        with tex_file.open(encoding="utf-8") as f:
            tex = f.readlines()
        for error_index in error_indices:
            print_tex_error(tex_compilation_log, error_index, tex)


LATEX_ERROR_INSIGHTS = [
    (
        r"inputenc Error: Unicode character (?:.*) \(U\+([0-9a-fA-F]+)\)",
        insight_inputenc_error,
    ),
    (
        r"LaTeX Error: File `(.*?[clsty])' not found",
        insight_package_not_found_error,
    ),
]


def print_tex_error(
    tex_compilation_log: Sequence[str],
    error_start_index: int,
    tex_source: Sequence[str],
) -> None:
    logger.error(
        f"LaTeX compilation error: {tex_compilation_log[error_start_index][2:]}",
    )

    # TeX errors eventually contain a line beginning 'l.xxx` where xxx is the line number that caused the compilation
    # failure. This code finds the next such line after the error current error message
    line_of_tex_error = (
        int(
            [
                log_line
                for log_line in tex_compilation_log[error_start_index:]
                if log_line.startswith("l.")
            ][0]
            .split(" ")[0]
            .split(".")[1],
        )
        - 1
    )
    # our tex error may be on a line outside our user input because of post-processing
    if line_of_tex_error >= len(tex_source):
        return None

    context = ["Context of error: \n"]
    if line_of_tex_error < 3:
        context += tex_source[: line_of_tex_error + 3]
        context[-4] = "-> " + context[-4]
    elif line_of_tex_error > len(tex_source) - 3:
        context += tex_source[line_of_tex_error - 1 :]
        context[1] = "-> " + context[1]
    else:
        context += tex_source[line_of_tex_error - 3 : line_of_tex_error + 3]
        context[-4] = "-> " + context[-4]

    context_joined = "".join(context)
    logger.error(context_joined)

    for insights in LATEX_ERROR_INSIGHTS:
        prob, get_insight = insights
        matching = re.search(
            prob,
            "".join(tex_compilation_log[error_start_index])[2:],
        )
        if matching is not None:
            for insight in get_insight(matching):
                logger.info(insight)
