# Copyright (c) 2023 Christophe Dufaza <chris@openmarl.org>
#
# SPDX-License-Identifier: Apache-2.0

"""SVG contents format for command output redirection.

Rationale:
- the SVG documents we can generate directly with the Textualize's rich library
  API do not really suit the DTSh use case
- we need an additional abstraction layer to properly support the "append" mode

The original fomrat of the SVG docment generated by the Textualize's rich
library is like bellow:

    <svg class="rich-terminal"
         viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">

    <style>

    <!-- Format strings for the "SVG Terminal" font (the default Fira Code),
         but unusable from the API: e.g.:
    -->
        font-size: {char_height}px;
        line-height: {line_height}px;

    <!-- where neither char_height nor line_height is actually part of the API,
         by hard-coded values in rich.Console.
         Only the font aspect ratio is.
    -->

    <!-- The format string bellow represent
         individual CSS rules (e.g. typeface e.g. bold, and colors)
         for the "SVG Terminal" cells when appropriate.
         Computed by export_svg() during document generation.
    -->
    {style}

    </style>

    <!-- SVG paths that represent the "SVG Terminal" lines' dimensions,
         computed by export_svg() during document generation.
    -->
    <defs>
        <cliPath id=...>
            <rect x=, y=, width= height=/>
        </cliPath>
    <defs>

    <!-- Then comes what "they" name the "chrome": -->

    <!-- First, a rectangle that fills the ViewBox's background,
         minus TBLR margins.
    -->
    <rect fill= width= height= />

    <!-- Then export_svg() insert an hard-coded SVG group that renders
         as macOS-like colored window buttons.
    -->
    <g transform="translate(26,22)">
        <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
        <circle cx="22" cy="0" r="7" fill="#febc2e"/>
        <circle cx="44" cy="0" r="7" fill="#28c840"/>
    </g>

    <!-- End of "chrome" -->

    <!-- Eventually comes the Terminal group, that is the SVG group that
         will represent the DTSh command's output.

         This group opens with a translation that sets the position
         of this group relative to the ViewBox, where:
         - x = left (margin + padding) = 1 + 8 = 9px
         - y = top (margin + to padding) = 1 + 40 = 41px

        The reason the top padding equals to 40 instead of 8 (all others)
        is to take into account the vertical space occupied by the
        "window decoration" (buttons + title).
    -->
    <g transform="translate(9, 41)" clip-path="">

        <!-- The group of all SVG text elements that render the actual
             Terminal's output:
             - row positions defined relative to the translated group
             - row dimensions defined by the <cliPath> elements
             - row contents styles defined by the CSS rules
        -->
        <g class="">
            <text class="" cliPath="" ../>output text</text>
        </g>
    </g>

    </svg>


Fonts
-----
We can't do much with the public API as long as the internal character height
remains inaccessible: we could set a CSS font-size property,
but this would mess with all <cliPath> elements, the computation of which
would still use the internal (and different) character height.

Setting the font-family and aspect ration would be quite simple though,
although not a priority.

We'll remove from the format string the corresponding Fira Code CSS rules,
which ask the SVG client for downloading a font that is probably not
installed on the OS, and replace them with a simple font specification.


ViewBox
-------
Setting the ViewBox will trigger SVG "zoom-in" if its dimensions are smaller
than the viewport, e.g. a Web browser's window: remvove it.


Window Decoration
-----------------
These macOS-like window buttons are at least unnecessarily,
and setting a title does not really match our use case,
that would rather be using the generated SVG file
as an image in another document(HTML, LaTeX, Word, etc),
thus setting its title and other "decorations" there.

We'll entirely remove title and buttons.
Assuming this is the reason for the difference between the top padding
and all others (left, bottom, right), we get the vertical space dedicated
to this window decoration as: 40 - 8 = 32px

Regarding titles: something more useful could be to "echo"
the command string to the file the command is redirected to.


Chrome's rectangle
-----------------
The rectangle's dimensions computed by export_svg() take into account
the title and buttons we removed.

We must then subtract 32px from the rectangle's height.

Rectangle dimensions are computed as:
- with: ceil(width * char_width + padding_width)
- height: (y + 1) * line_height + padding_height


Terminal group coordinates
----------------------------
As we've seen, the Terminal's coordinates are const expressions:
- x: margin_left + padding_left = 1 + 8 = 9
- y: margin_top + padding_top = 1 + 40 = 41

Since we removed 32px of previous vertical space,
we must subtract these from y.

Note: this is the same as assuming the top padding equals to 8, giving x=y=9.


Append mode
-----------
When "appending" SVG' to SVG:
- the <style> contents is SVG(<style/>) + SVG(<style/>)'
- the <defs> contents is SVG(<defs/>) + SVG'(<defs/>)

Chrome's rectangle:
- width: max(SVG(width), SVG'(width))
- height: SVG(height) + SVG'(height)

Terminal group coordinates:
- x: SVG'(x) = SVG(x)
- y: SVG'(y) + SVG(height)

"""


from typing import Tuple, List, Sequence

import math
import re


class SVGContents:
    """Structured contents of SVG files."""

    class GTerm:
        """Terminal's SVG group."""

        _x: int
        _y: int
        _contents: List[str]

        def __init__(self, contents: Sequence[str]) -> None:
            """
            Initialize Group.

            Raises:
                SVGContentsFmt.Error: Malformed SVG translation.
            """
            self._contents = list(contents)
            self._x, self._y = SVGContentsFmt.get_gtranslate_xy(
                self._contents[0]
            )

        @property
        def x(self) -> int:
            """The Terminal group's horizontal coordinate."""
            return self._x

        @property
        def y(self) -> int:
            """The Terminal group's vertical coordinate."""
            return self._y

        @property
        def contents(self) -> Sequence[str]:
            """The Terminal group's contents."""
            return self._contents

        def set_xy(self, x: int, y: int) -> None:
            """Update the Terminal group coordinates.

            This updates the translation of the SVG group.

            Args:
                x: The horizontal translation.
                y: The vertical translation.

            Raises:
                SVGContentsFmt.Error: Malformed SVG translation.
            """
            gtranslate = self._contents[0]
            self._contents[0] = SVGContentsFmt.set_gtranslate_xy(
                gtranslate, x, y
            )
            self._x, self._y = x, y

    # <style/> contents
    _styles: List[str]

    # <defs/> contents
    _defs: List[str]

    # The chrome's rectangle tag.
    _rect: str
    # The chrome's width.
    _width: int
    # The chrome's height.
    _height: int

    # Terminal groups.
    _gterms: List["SVGContents.GTerm"]

    def __init__(self, contents: Sequence[str]) -> None:
        """Initialize SVG contents.

        Args:
            contents: The SVG contents line to parse into the expected
              structured content.

        Raises:
            SVGContentsFmt.Error: Unexpected SVG contents.
        """
        i_contents = 0

        # Parse styles.
        begin, end = SVGContentsFmt.find_styles(contents, i_contents)
        # Exclude the lines with <style> and </style>.
        self._styles = list(contents[begin + 1 : end])
        i_contents = end + 1

        # Parse SVG definitions.
        begin, end = SVGContentsFmt.find_defs(contents, i_contents)
        # Exclude the lines with <defs> and </defs>.
        self._defs = list(contents[begin + 1 : end])
        i_contents = end + 1

        # Parse chrome's rectangle.
        i_chrome = SVGContentsFmt.find_chrome(contents, i_contents)
        i_rect = i_chrome + 1
        self._rect = contents[i_rect]
        self._width, self._height = SVGContentsFmt.get_rect_width_heigt(
            self._rect
        )
        i_contents = i_rect + 1

        # Parse Terminal SVG groups.
        self._gterms = []
        while True:
            try:
                begin, end = SVGContentsFmt.find_gterm(contents, i_contents)
                # Exclude the lines with ":gterm_begin" and "gterm_end".
                self._gterms.append(
                    SVGContents.GTerm(contents[begin + 1 : end])
                )
                i_contents = end + 1
            except SVGContentsFmt.Error:
                # Assuming no more Terminal groups.
                break

    @property
    def styles(self) -> Sequence[str]:
        """Contents lines for CSS styles and rules.

        This is the contents of the <style> element.
        """
        return self._styles

    @property
    def defs(self) -> Sequence[str]:
        """Contents lines for the SVG paths definitions.

        This is the contents of the <defs> element.
        """
        return self._defs

    @property
    def rect(self) -> str:
        """The chrome's rectangle tag."""
        return self._rect

    @property
    def width(self) -> int:
        """The chrome's rectangle width."""
        return self._width

    @property
    def height(self) -> int:
        """The chrome's rectangle height."""
        return self._height

    @property
    def gterm(self) -> "SVGContents.GTerm":
        """The first parsed Terminal group.

        This is the unique one unless in append mode.
        """
        return self._gterms[0]

    @property
    def gterms(self) -> Sequence["SVGContents.GTerm"]:
        """All parsed Terminal groups."""
        return self._gterms

    @property
    def term_x(self) -> int:
        """The horizontal coordinate of the first parsed Terminal group."""
        return self._gterms[0].x

    @property
    def term_y(self) -> int:
        """The vertical coordinate of the first parsed Terminal group."""
        return self._gterms[0].y

    def top_padding_correction(self) -> None:
        """Top padding correction.

        Update the chrome rectangle's width and the Terminal group's translation
        to take into account the top padding correction (window decoration).
        """
        # Fix chrome's rect.
        height = self.height
        self.set_rect_width_height(
            self.width, height - SVGContentsFmt.PAD_TOP_CORRECTION
        )
        # Fix gterm's translation.
        y = self.term_y
        self.set_gterm_xy(self.term_x, y - SVGContentsFmt.PAD_TOP_CORRECTION)

    def set_rect_width_height(self, width: int, height: int) -> None:
        """Update the chrome's rectangle dimensions.

        This updates the chrome's rectangle tag.

        Args:
            width: The new width.
            height: The new height.
        """
        self._width, self._height = width, height
        self._rect = SVGContentsFmt.set_rect_width_height(
            self._rect, self._width, self._height
        )

    def set_gterm_xy(self, x: int, y: int) -> None:
        """Update the coordinates of the first parsed Terminal group.

        Args:
            x: The horizontal translation.
            y: The vertical translation.
        """
        self._gterms[0].set_xy(x, y)

    def append(self, svg_append: "SVGContents") -> None:
        """Append another SVG contents to this one.

        Args:
            svg_append: The SVG contents to append.
        """
        # Append styles.
        self._styles.extend(svg_append.styles)

        # Append SVG path definitions.
        self._defs.extend(svg_append.defs)

        # Update appended Terminal group coordinates to take into account
        # the preceding ones.
        # NOTE: We must do this before updating our own height.
        y: int = svg_append.term_y + self.height
        svg_append.set_gterm_xy(self.term_x, y)

        # Update chrome's rectangle.
        width: int = max(self.width, svg_append.width)
        height: int = self.height + svg_append.height
        self.set_rect_width_height(width, height)

        # Append the Terminal group to this SVG contents.
        self._gterms.append(svg_append.gterm)


class SVGContentsFmt:
    """SVG contents format for command output redirection."""

    class Error(BaseException):
        """SVG format error."""

    CSS_STYLES_BEGIN = "<style>"
    """Where start the CSS styles and rules.

    This includes the setting the Terminals's font.
    """

    CSS_STYLES_END = "</style>"
    """End of styles."""

    SVG_DEFS_BEGIN = "<defs>"
    """Where start the SVG paths definitions."""

    SVG_DEFS_END = "</defs>"
    """End of styles."""

    MARK_CHROME = "    <!-- :chrome: -->"
    """Where the "chrome" starts.

        <!-- :chrome: -->
        <rect fill= width= height= />
            <g transform="translate(26,22)">
                <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
                <circle cx="22" cy="0" r="7" fill="#febc2e"/>
                <circle cx="44" cy="0" r="7" fill="#28c840"/>
            </g>
    """

    MARK_GTERM_BEGIN = "    <!-- :gterm_begin: -->"
    """Where the Terminal's output (redirected command) starts.

        <!-- :gterm_begin: -->
        <g transform="translate({terminal_x}, {terminal_y})" clip-path=>
            <g class="{unique_id}-matrix">
            {matrix}
            </g>
        </g>
        <!-- :gterm_end: -->
    """

    MARK_GTERM_END = "    <!-- :gterm_end: -->"
    """End of Terminal's SVG group."""

    FONT_PLACEHOLDER = "|font_family|"
    """Placeholder string for the font family."""

    PROLOG = '<svg xmlns="http://www.w3.org/2000/svg">'

    EPILOG = "</svg>"

    PAD_TOP_CORRECTION: int = 32
    """Assumed height of the macOS-like window decoration in pixels.

    We'll apply this correction to the vertical dimensions of the
    SVG document generated by Console.svg_export().
    """

    @classmethod
    def get_format(cls, font_family: str = "Source Code Pro") -> str:
        """Make an SVG format string parameter value.

        Args:
            font_family: A font family string, like "Source Code Pro".

        Returns:
            A format string compatible with the export_sgv() API.
        """
        return DTSH_SVG_FORMAT.replace(cls.FONT_PLACEHOLDER, font_family, 1)

    @classmethod
    def find(cls, mark: str, contents: Sequence[str], start: int = 0) -> int:
        """Find mark in contents.

        mark: The string to find.
        contents: The SVG contents to search.
        start: Start offset in contents (in number of lines).

        Returns:
            The index of the first contents line where the mark is found,
            or -1 if not found.
        """
        for i, line in enumerate(contents[start:]):
            if line.find(mark) != -1:
                return i + start
        return -1

    @classmethod
    def find_range(
        cls,
        mark_begin: str,
        mark_end: str,
        contents: Sequence[str],
        start: int = 0,
    ) -> Tuple[int, int]:
        """Find the lines range between begin and end marks.

        Args:
            mark_begin: Mark where the range begins.
            mark_end: Mark where the range ends.
            contents: The SVG contents to search.
            start: Start offset in contents (in number of lines).

        Returns:
            The found range as the tuple (begin, end).

        Raises:
            SVGContentsFmt.Error: Unexpected SVG format.
        """
        begin = cls.find(mark_begin, contents, start)
        if begin == -1:
            raise SVGContentsFmt.Error(mark_begin)
        end = cls.find(mark_end, contents, begin + 1)
        if end == -1:
            raise SVGContentsFmt.Error(mark_end)
        return (begin, end)

    @classmethod
    def find_styles(
        cls, contents: Sequence[str], start: int = 0
    ) -> Tuple[int, int]:
        """Find the CSS styles' range.

        contents: The SVG contents to search.
        start: Start offset in contents (in number of lines).

        Returns:
            The range from CSS styles start (the "<style>" line).
            to end (the "</style>" line).

        Raises:
            SVGContentsFmt.Error: Unexpected SVG format.
        """
        begin, end = cls.find_range(
            cls.CSS_STYLES_BEGIN, cls.CSS_STYLES_END, contents, start
        )
        return (begin, end)

    @classmethod
    def find_defs(
        cls, contents: Sequence[str], start: int = 0
    ) -> Tuple[int, int]:
        """Find SVG definitions' range.

        contents: The SVG contents to search.
        start: Start offset in contents (in number of lines).

        Returns:
            The range from definitions start (the "<defs>" line).
            to end (the "</defs>" line).

        Raises:
            SVGContentsFmt.Error: Unexpected SVG format.
        """
        begin, end = cls.find_range(
            cls.SVG_DEFS_BEGIN, cls.SVG_DEFS_END, contents, start
        )
        return (begin, end)

    @classmethod
    def find_chrome(cls, contents: Sequence[str], start: int = 0) -> int:
        """Find where the chrome starts (see MARK_CHROME).

        contents: The SVG contents to search.
        start: Start offset in contents (in number of lines).

        Returns:
            The index of the line with the chrome's mark.

        Raises:
            SVGContentsFmt.Error: Unexpected SVG format.
        """
        i_chrome = cls.find(cls.MARK_CHROME, contents, start)
        if i_chrome == -1:
            raise SVGContentsFmt.Error(cls.MARK_CHROME)
        return i_chrome

    @classmethod
    def find_gterm(
        cls, contents: Sequence[str], start: int = 0
    ) -> Tuple[int, int]:
        """Find the Terminal's SVG group (see MARK_TERM_START).

        contents: The SVG contents to search.
        start: Start offset in contents (in number of lines).

        Returns:
            The range from group start (the line with the begin mark),
            to end (the line with the end mark).

        Raises:
            SVGContentsFmt.Error: Unexpected SVG format.
        """
        begin, end = cls.find_range(
            cls.MARK_GTERM_BEGIN, cls.MARK_GTERM_END, contents, start
        )
        return (begin, end)

    @classmethod
    def get_gtranslate_xy(cls, gtranslate: str) -> Tuple[int, int]:
        """Get the coordinates of an SVG group translation.

        The SVG group must start with something like:
            <g transform="translate(x, y)

        where x and y are pixels in integers.

        Args:
            gtranslate: The SVG group tag.

        Returns:
            The (x,y) tuple.

        Raises:
            SVGContentsFmt.Error: Malformed SVG translation.
        """
        m = cls._RE_TRANSFORM_XY.match(gtranslate)
        if m:
            return (int(m.group("x")), int(m.group("y")))
        raise SVGContentsFmt.Error(gtranslate)

    @classmethod
    def set_gtranslate_xy(cls, gtranslate: str, x: int, y: int) -> str:
        """Set the coordinates of an SVG group translation.

        See also get_gtranslate_xy().

        Args:
            gtranslate: The SVG group tag.
            x: The horizontal translation.
            y: The vertical translation.

        Returns:
            The SVG group tag with updated coordinates.

        Raises:
            SVGContentsFmt.Error: Malformed SVG translation.
        """
        m = cls._RE_TRANSFORM_TRANSLATE.match(gtranslate)
        if m:
            translate: str = m.group("translate")
            return gtranslate.replace(translate, f"translate({x}, {y})", 1)
        raise SVGContentsFmt.Error(gtranslate)

    @classmethod
    def get_rect_width_heigt(cls, rect: str) -> Tuple[int, int]:
        """Get the width and height of an SVG rectangle.

        The SVG rectangle is something like:
            <rect attr="value" width="w" height="h"

        where w and h are float numbers.

        Args:
            rect: The SVG rectangle tag.

        Returns:
            The (width, height) tuple, ceils of the float width and height.

        Raises:
            SVGContentsFmt.Error: Malformed SVG rectangle.
        """
        m = cls._RE_RECT.match(rect)
        if m:
            width: int = math.ceil(float(m.group("w")))
            height: int = math.ceil(float(m.group("h")))
            return (width, height)
        raise SVGContentsFmt.Error(rect)

    @classmethod
    def set_rect_width_height(cls, rect: str, width: int, height: int) -> str:
        """Set the width and height of an SVG rectangle.

        Args:
            rect: The SVG rectangle tag.
            width: The new width.
            height: The new height.

        Returns:
            The SVG rectangle tag with updated width and height.

        Raises:
            SVGContentsFmt.Error: Malformed SVG rectangle.
        """
        m_width = cls._RE_RECT_WIDTH.match(rect)
        m_height = cls._RE_RECT_HEIGHT.match(rect)
        if m_width and m_height:
            rect_width: str = m_width.group("w")
            rect_height: str = m_height.group("h")
            rect = rect.replace(rect_width, f'width="{width}"')
            rect = rect.replace(rect_height, f'height="{height}"')
            return rect
        raise SVGContentsFmt.Error(rect)

    # <g transform="translate(x, y)" clip-path="...">
    _RE_TRANSFORM_XY = re.compile(
        r'\s*<g transform="translate\((?P<x>[\d]+),\s*(?P<y>[\d]+)\)'
    )

    _RE_TRANSFORM_TRANSLATE = re.compile(
        r'\s*<g transform="(?P<translate>translate\([\d,\s]+\))'
    )

    # <rect ... width="w" height="h" .../>
    _RE_RECT = re.compile(
        r'\s*<rect\s+.*width="(?P<w>[\d.]+)".*height="(?P<h>[\d.]+)"'
    )

    _RE_RECT_WIDTH = re.compile(r'\s*<rect\s+.*(?P<w>width="[\d.]+")')
    _RE_RECT_HEIGHT = re.compile(r'\s*<rect\s+.*(?P<h>height="[\d.]+")')


DTSH_SVG_FORMAT = """\
<svg xmlns="http://www.w3.org/2000/svg">

    <style>

    .{unique_id}-matrix {{
        font-family: |font_family|, monospace;
        font-size: {char_height}px;
        line-height: {line_height}px;
    }}
    {styles}

    </style>

    <defs>
        <clipPath id="{unique_id}-clip-terminal">
            <rect x="0" y="0" width="{terminal_width}" height="{terminal_height}" />
        </clipPath>
        {lines}

    </defs>

    <!-- :chrome: -->
    {chrome}

    <!-- :gterm_begin: -->
    <g transform="translate({terminal_x}, {terminal_y})" clip-path="url(#{unique_id}-clip-terminal)">
        <g class="{unique_id}-matrix">
        {matrix}
        </g>
    </g>
    <!-- :gterm_end: -->

</svg>
"""
