# Copyright (c) 2021-2025, Manfred Moitzi
# License: MIT License

# This is the abstract link between the text layout engine implemented in
# ezdxf.tools.text_layout and a concrete MTEXT renderer implementation like
# MTextExplode or ComplexMTextRenderer.
from __future__ import annotations
from typing import Sequence, Optional
import abc
from ezdxf.lldxf import const
from ezdxf import colors
from ezdxf.entities.mtext import MText, MTextColumns
from ezdxf.entities.textstyle import get_textstyle
from ezdxf.enums import (
    MTextParagraphAlignment,
)
from ezdxf.fonts import fonts
from ezdxf.tools import text_layout as tl
from ezdxf.tools.text import (
    MTextParser,
    MTextContext,
    TokenType,
    ParagraphProperties,
    estimate_mtext_extents,
    valid_text_height,
)

__all__ = ["AbstractMTextRenderer"]

ALIGN = {
    MTextParagraphAlignment.LEFT: tl.ParagraphAlignment.LEFT,
    MTextParagraphAlignment.RIGHT: tl.ParagraphAlignment.RIGHT,
    MTextParagraphAlignment.CENTER: tl.ParagraphAlignment.CENTER,
    MTextParagraphAlignment.JUSTIFIED: tl.ParagraphAlignment.JUSTIFIED,
    MTextParagraphAlignment.DISTRIBUTED: tl.ParagraphAlignment.JUSTIFIED,
    MTextParagraphAlignment.DEFAULT: tl.ParagraphAlignment.LEFT,
}

ATTACHMENT_POINT_TO_ALIGN = {
    const.MTEXT_TOP_LEFT: tl.ParagraphAlignment.LEFT,
    const.MTEXT_MIDDLE_LEFT: tl.ParagraphAlignment.LEFT,
    const.MTEXT_BOTTOM_LEFT: tl.ParagraphAlignment.LEFT,
    const.MTEXT_TOP_CENTER: tl.ParagraphAlignment.CENTER,
    const.MTEXT_MIDDLE_CENTER: tl.ParagraphAlignment.CENTER,
    const.MTEXT_BOTTOM_CENTER: tl.ParagraphAlignment.CENTER,
    const.MTEXT_TOP_RIGHT: tl.ParagraphAlignment.RIGHT,
    const.MTEXT_MIDDLE_RIGHT: tl.ParagraphAlignment.RIGHT,
    const.MTEXT_BOTTOM_RIGHT: tl.ParagraphAlignment.RIGHT,
}

STACKING = {
    "^": tl.Stacking.OVER,
    "/": tl.Stacking.LINE,
    "#": tl.Stacking.SLANTED,
}


def make_default_tab_stops(cap_height: float, width: float) -> list[tl.TabStop]:
    tab_stops = []
    cap_height = valid_text_height(cap_height)
    step = 4.0 * cap_height
    pos = step
    while pos < width:
        tab_stops.append(tl.TabStop(pos, tl.TabStopType.LEFT))
        pos += step
    return tab_stops


def append_default_tab_stops(
    tab_stops: list[tl.TabStop], default_stops: Sequence[tl.TabStop]
) -> None:
    last_pos = 0.0
    if tab_stops:
        last_pos = tab_stops[-1].pos
    tab_stops.extend(stop for stop in default_stops if stop.pos > last_pos)


def make_tab_stops(
    cap_height: float,
    width: float,
    tab_stops: Sequence,
    default_stops: Sequence[tl.TabStop],
) -> list[tl.TabStop]:
    cap_height = valid_text_height(cap_height)
    _tab_stops = []
    for stop in tab_stops:
        if isinstance(stop, str):
            value = float(stop[1:])
            if stop[0] == "c":
                kind = tl.TabStopType.CENTER
            else:
                kind = tl.TabStopType.RIGHT
        else:
            kind = tl.TabStopType.LEFT
            value = float(stop)
        pos = value * cap_height
        if pos < width:
            _tab_stops.append(tl.TabStop(pos, kind))

    append_default_tab_stops(_tab_stops, default_stops)
    return _tab_stops


def get_stroke(ctx: MTextContext) -> int:
    stroke = 0
    if ctx.underline:
        stroke += tl.Stroke.UNDERLINE
    if ctx.strike_through:
        stroke += tl.Stroke.STRIKE_THROUGH
    if ctx.overline:
        stroke += tl.Stroke.OVERLINE
    if ctx.continue_stroke:
        stroke += tl.Stroke.CONTINUE
    return stroke


def new_paragraph(
    cells: list,
    ctx: MTextContext,
    cap_height: float,
    line_spacing: float = 1,
    width: float = 0,
    default_stops: Optional[Sequence[tl.TabStop]] = None,
):
    cap_height = valid_text_height(cap_height)
    if cells:
        p = ctx.paragraph
        align = ALIGN.get(p.align, tl.ParagraphAlignment.LEFT)
        left = p.left * cap_height
        right = p.right * cap_height
        first = left + p.indent * cap_height  # relative to left
        _default_stops: Sequence[tl.TabStop] = default_stops or []
        tab_stops = _default_stops
        if p.tab_stops:
            tab_stops = make_tab_stops(cap_height, width, p.tab_stops, _default_stops)
        paragraph = tl.Paragraph(
            align=align,
            indent=(first, left, right),
            line_spacing=line_spacing,
            tab_stops=tab_stops,
        )
        paragraph.append_content(cells)
    else:
        paragraph = tl.EmptyParagraph(  # type: ignore
            cap_height=ctx.cap_height, line_spacing=line_spacing
        )
    return paragraph


def super_glue():
    return tl.NonBreakingSpace(width=0, min_width=0, max_width=0)


def defined_width(mtext: MText) -> float:
    width = mtext.dxf.get("width", 0.0)
    if width < 1e-6:
        width, height = estimate_mtext_extents(mtext)
    return width


def column_heights(columns: MTextColumns) -> list[Optional[float]]:
    heights: list[Optional[float]]
    if columns.heights:  # dynamic manual
        heights = list(columns.heights)
        # last height has to be auto height = None
        heights[-1] = None
        return heights
    # static, dynamic auto
    defined_height = abs(columns.defined_height)
    if defined_height < 1e-6:
        return [None]
    return [defined_height] * columns.count


class AbstractMTextRenderer(abc.ABC):
    def __init__(self) -> None:
        self._font_cache: dict[tuple[str, float, float], fonts.AbstractFont] = {}

    @abc.abstractmethod
    def word(self, test: str, ctx: MTextContext) -> tl.ContentCell: ...

    @abc.abstractmethod
    def fraction(
        self, data: tuple[str, str, str], ctx: MTextContext
    ) -> tl.ContentCell: ...

    @abc.abstractmethod
    def get_font_face(self, mtext: MText) -> fonts.FontFace: ...

    @abc.abstractmethod
    def make_bg_renderer(self, mtext: MText) -> tl.ContentRenderer: ...

    def make_mtext_context(self, mtext: MText) -> MTextContext:
        ctx = MTextContext()
        ctx.paragraph = ParagraphProperties(
            align=ATTACHMENT_POINT_TO_ALIGN.get(  # type: ignore
                mtext.dxf.attachment_point, tl.ParagraphAlignment.LEFT
            )
        )
        ctx.font_face = self.get_font_face(mtext)
        ctx.cap_height = valid_text_height(mtext.dxf.char_height)
        ctx.aci = mtext.dxf.color
        textstyle = get_textstyle(mtext)
        ctx.width_factor = textstyle.dxf.width
        ctx.oblique = textstyle.dxf.oblique
        rgb = mtext.rgb
        if rgb is not None:
            ctx.rgb = colors.RGB(*rgb)
        return ctx

    def get_font(self, ctx: MTextContext) -> fonts.AbstractFont:
        ttf = fonts.find_font_file_name(ctx.font_face)  # 1st call is very slow
        key = (ttf, ctx.cap_height, ctx.width_factor)
        font = self._font_cache.get(key)
        if font is None:
            font = fonts.make_font(ttf, ctx.cap_height, ctx.width_factor)
            self._font_cache[key] = font
        return font

    def get_stroke(self, ctx: MTextContext) -> int:
        return get_stroke(ctx)

    def get_stacking(self, type_: str) -> tl.Stacking:
        return STACKING.get(type_, tl.Stacking.LINE)

    def space_width(self, ctx: MTextContext) -> float:
        return self.get_font(ctx).space_width()

    def space(self, ctx: MTextContext):
        return tl.Space(width=self.space_width(ctx))

    def tabulator(self, ctx: MTextContext):
        return tl.Tabulator(width=self.space_width(ctx))

    def non_breaking_space(self, ctx: MTextContext):
        return tl.NonBreakingSpace(width=self.space_width(ctx))

    def layout_engine(self, mtext: MText) -> tl.Layout:
        initial_cap_height = valid_text_height(mtext.dxf.char_height)
        line_spacing = mtext.dxf.line_spacing_factor

        def append_paragraph():
            paragraph = new_paragraph(
                cells,
                ctx,
                initial_cap_height,
                line_spacing,
                width,
                default_stops,
            )
            layout.append_paragraphs([paragraph])
            cells.clear()

        bg_renderer = self.make_bg_renderer(mtext)
        width = defined_width(mtext)
        default_stops = make_default_tab_stops(initial_cap_height, width)
        layout = tl.Layout(width=width)
        if mtext.has_columns:
            columns = mtext.columns
            assert columns is not None
            for height in column_heights(columns):
                layout.append_column(
                    width=columns.width,
                    height=height,
                    gutter=columns.gutter_width,
                    renderer=bg_renderer,
                )
        else:
            # column with auto height and default width
            layout.append_column(renderer=bg_renderer)

        content = mtext.all_columns_raw_content()
        ctx = self.make_mtext_context(mtext)
        cells: list[tl.Cell] = []
        for token in MTextParser(content, ctx):
            ctx = token.ctx
            if token.type == TokenType.NEW_PARAGRAPH:
                append_paragraph()
            elif token.type == TokenType.NEW_COLUMN:
                append_paragraph()
                layout.next_column()
            elif token.type == TokenType.SPACE:
                cells.append(self.space(ctx))
            elif token.type == TokenType.NBSP:
                cells.append(self.non_breaking_space(ctx))
            elif token.type == TokenType.TABULATOR:
                cells.append(self.tabulator(ctx))
            elif token.type == TokenType.WORD:
                if cells and isinstance(cells[-1], (tl.Text, tl.Fraction)):
                    # Create an unbreakable connection between those two parts.
                    cells.append(super_glue())
                cells.append(self.word(token.data, ctx))
            elif token.type == TokenType.STACK:
                if cells and isinstance(cells[-1], (tl.Text, tl.Fraction)):
                    # Create an unbreakable connection between those two parts.
                    cells.append(super_glue())
                cells.append(self.fraction(token.data, ctx))

        if cells:
            append_paragraph()

        return layout
