#  Copyright (c) 2023, Manfred Moitzi
#  License: MIT License
#
# Basic tools for the LibreCAD Font Format:
# https://github.com/Rallaz/LibreCAD/wiki/lff-definition
from __future__ import annotations
from typing import Sequence, Iterator, Iterable, Optional, no_type_check
from typing_extensions import TypeAlias
from ezdxf.math import Vec2, BoundingBox2d, Matrix44
from ezdxf import path
from .font_measurements import FontMeasurements
from .glyphs import GlyphPath, Glyphs

__all__ = ["loads", "LCFont", "Glyph", "GlyphCache"]


def loads(s: str) -> LCFont:
    lines = s.split("\n")
    name, letter_spacing, word_spacing = parse_properties(lines)
    lcf = LCFont(name, letter_spacing, word_spacing)
    for glyph, parent_code in parse_glyphs(lines):
        lcf.add(glyph, parent_code)
    return lcf


class LCFont:
    """Low level representation of LibreCAD fonts."""
    def __init__(
        self, name: str = "", letter_spacing: float = 0.0, word_spacing: float = 0.0
    ) -> None:
        self.name: str = name
        self.letter_spacing: float = letter_spacing
        self.word_spacing: float = word_spacing
        self._glyphs: dict[int, Glyph] = dict()

    def __len__(self) -> int:
        return len(self._glyphs)

    def __getitem__(self, item: int) -> Glyph:
        return self._glyphs[item]

    def add(self, glyph: Glyph, parent_code: int = 0) -> None:
        if parent_code:
            try:
                parent_glyph = self._glyphs[parent_code]
            except KeyError:
                return
            glyph = parent_glyph.extend(glyph)
        self._glyphs[glyph.code] = glyph

    def get(self, code: int) -> Optional[Glyph]:
        return self._glyphs.get(code, None)


Polyline: TypeAlias = Sequence[Sequence[float]]


class Glyph:
    """Low level representation of a LibreCAD glyph."""
    __slots__ = ("code", "polylines")

    def __init__(self, code: int, polylines: Sequence[Polyline]):
        self.code: int = code
        self.polylines: Sequence[Polyline] = tuple(polylines)

    def extend(self, glyph: Glyph) -> Glyph:
        polylines = list(self.polylines)
        polylines.extend(glyph.polylines)
        return Glyph(glyph.code, polylines)

    def to_path(self) -> GlyphPath:
        from ezdxf.math import OCS

        final_path = path.Path()
        ocs = OCS()
        for polyline in self.polylines:
            p = path.Path()  # empty path is required
            path.add_2d_polyline(
                p, convert_bulge_values(polyline), close=False, elevation=0, ocs=ocs
            )
            final_path.extend_multi_path(p)
        return final_path.to_2d_path()


def convert_bulge_values(polyline: Polyline) -> Iterator[Sequence[float]]:
    # In DXF the bulge value is always stored at the start vertex of the arc.
    last_index = len(polyline) - 1
    for index, vertex in enumerate(polyline):
        bulge = 0.0
        if index < last_index:
            next_vertex = polyline[index + 1]
            try:
                bulge = next_vertex[2]
            except IndexError:
                pass
        yield vertex[0], vertex[1], bulge


def parse_properties(lines: list[str]) -> tuple[str, float, float]:
    font_name = ""
    letter_spacing = 0.0
    word_spacing = 0.0
    for line in lines:
        line = line.strip()
        if not line.startswith("#"):
            continue
        try:
            name, value = line.split(":")
        except ValueError:
            continue

        name = name[1:].strip()
        if name == "Name":
            font_name = value.strip()
        elif name == "LetterSpacing":
            try:
                letter_spacing = float(value)
            except ValueError:
                continue
        elif name == "WordSpacing":
            try:
                word_spacing = float(value)
            except ValueError:
                continue
    return font_name, letter_spacing, word_spacing


def scan_glyphs(lines: Iterable[str]) -> Iterator[list[str]]:
    glyph: list[str] = []
    for line in lines:
        if line.startswith("["):
            if glyph:
                yield glyph
            glyph.clear()
        if line:
            glyph.append(line)
    if glyph:
        yield glyph


def strip_clutter(lines: list[str]) -> Iterator[str]:
    for line in lines:
        line = line.strip()
        if not line.startswith("#"):
            yield line


def scan_int_ex(s: str) -> int:
    from string import hexdigits

    if len(s) == 0:
        return 0
    try:
        end = s.index("]")
    except ValueError:
        end = len(s)
    s = s[1:end].lower()
    s = "".join(c for c in s if c in hexdigits)
    try:
        return int(s, 16)
    except ValueError:
        return 0


def parse_glyphs(lines: list[str]) -> Iterator[tuple[Glyph, int]]:
    code: int
    polylines: list[Polyline] = []
    for glyph in scan_glyphs(strip_clutter(lines)):
        parent_code: int = 0
        polylines.clear()
        line = glyph.pop(0)
        if line[0] != "[":
            continue
        try:
            code = int(line[1 : line.index("]")], 16)
        except ValueError:
            code = scan_int_ex(line)
        if code == 0:
            continue
        line = glyph[0]
        if line.startswith("C"):
            glyph.pop(0)
            try:
                parent_code = int(line[1:], 16)
            except ValueError:
                continue
        polylines = list(parse_polylines(glyph))
        yield Glyph(code, polylines), parent_code


def parse_polylines(lines: Iterable[str]) -> Iterator[Polyline]:
    polyline: list[Sequence[float]] = []
    for line in lines:
        polyline.clear()
        for vertex in line.split(";"):
            values = to_floats(vertex.split(","))
            if len(values) > 1:
                polyline.append(values[:3])
        yield tuple(polyline)


def to_floats(values: Iterable[str]) -> Sequence[float]:
    def strip(value: str) -> float:
        if value.startswith("A"):
            value = value[1:]
        try:
            return float(value)
        except ValueError:
            return 0.0

    return tuple(strip(value) for value in values)


class GlyphCache(Glyphs):
    """Text render engine for LibreCAD fonts with integrated glyph caching."""

    def __init__(self, font: LCFont) -> None:
        self.font: LCFont = font
        self._glyph_cache: dict[int, GlyphPath] = dict()
        self._advance_width_cache: dict[int, float] = dict()
        self.space_width: float = self.font.word_spacing
        self.empty_box: GlyphPath = self.get_empty_box()
        self.font_measurements: FontMeasurements = self._get_font_measurements()

    def get_scaling_factor(self, cap_height: float) -> float:
        try:
            return cap_height / self.font_measurements.cap_height
        except ZeroDivisionError:
            return 1.0

    def get_empty_box(self) -> GlyphPath:
        glyph_A = self.get_shape(65)
        box = BoundingBox2d(glyph_A.control_vertices())
        height = box.size.y
        width = box.size.x
        start = glyph_A.start
        p = GlyphPath(start)
        p.line_to(start + Vec2(width, 0))
        p.line_to(start + Vec2(width, height))
        p.line_to(start + Vec2(0, height))
        p.close()
        p.move_to(glyph_A.end)
        return p

    def _render_shape(self, shape_number) -> GlyphPath:
        try:
            glyph = self.font[shape_number]
        except KeyError:
            if shape_number > 32:
                return self.empty_box
            raise ValueError("space and non-printable characters are not glyphs")
        return glyph.to_path()

    def get_shape(self, shape_number: int) -> GlyphPath:
        if shape_number <= 32:
            raise ValueError("space and non-printable characters are not glyphs")
        try:
            return self._glyph_cache[shape_number]
        except KeyError:
            pass
        glyph = self._render_shape(shape_number)
        self._glyph_cache[shape_number] = glyph
        advance_width = 0.0
        if len(glyph):
            box = BoundingBox2d(glyph.control_vertices())
            assert box.extmax is not None
            advance_width = box.extmax.x + self.font.letter_spacing
        self._advance_width_cache[shape_number] = advance_width
        return glyph

    def get_advance_width(self, shape_number: int) -> float:
        if shape_number < 32:
            return 0.0
        if shape_number == 32:
            return self.space_width
        try:
            return self._advance_width_cache[shape_number]
        except KeyError:
            pass
        _ = self.get_shape(shape_number)
        return self._advance_width_cache[shape_number]

    @no_type_check
    def _get_font_measurements(self) -> FontMeasurements:
        # ignore last move_to command, which places the pen at the start of the
        # following glyph
        bbox = BoundingBox2d(self.get_shape(ord("x")).control_vertices())
        baseline = bbox.extmin.y
        x_height = bbox.extmax.y - baseline

        bbox = BoundingBox2d(self.get_shape(ord("A")).control_vertices())
        cap_height = bbox.extmax.y - baseline
        bbox = BoundingBox2d(self.get_shape(ord("p")).control_vertices())
        descender_height = baseline - bbox.extmin.y
        return FontMeasurements(
            baseline=baseline,
            cap_height=cap_height,
            x_height=x_height,
            descender_height=descender_height,
        )

    def get_text_length(
        self, text: str, cap_height: float, width_factor: float = 1.0
    ) -> float:
        scaling_factor = self.get_scaling_factor(cap_height) * width_factor
        return sum(self.get_advance_width(ord(c)) for c in text) * scaling_factor

    def get_text_path(
        self, text: str, cap_height: float, width_factor: float = 1.0
    ) -> GlyphPath:
        p = GlyphPath()
        sy = self.get_scaling_factor(cap_height)
        sx = sy * width_factor
        m = Matrix44.scale(sx, sy, 1)
        current_location = 0.0
        for c in text:
            shape_number = ord(c)
            if shape_number > 32:
                glyph = self.get_shape(shape_number)
                m[3, 0] = current_location
                p.extend_multi_path(glyph.transform(m))
            current_location += self.get_advance_width(shape_number) * sx
        if not p.end.isclose((current_location, 0)):
            p.move_to((current_location, 0))
        return p
