"""
Tests for RTL (right-to-left) layout support across the translators.

Covers the spec matrix:
- is_rtl(): regional prefixes (fa-IR, ar-EG) and case are normalized
- Word: w:bidi on body / footnotes / text boxes, w:bidiVisual on tables,
  w:bidi on sectPr, explicit alignments (center) never overwritten
- PowerPoint: rtl="1" on paragraphs, algn kept when explicit (ctr) and
  only forced to "r" when absent or "l", speaker notes RTL,
  <a:cs typeface="Arial"> hints for RTL targets
- Excel: sheet_view.rightToLeft = True on every sheet
- PDF: Arabic-script font selection per target language, bidi shaping
  (arabic-reshaper + python-bidi), graceful fallback when the shaping
  libraries are missing, wordWrap="RTL" + registered TTF font in the
  clean-PDF mode
- Non-RTL targets (fr): no RTL attribute added anywhere
"""

import sys
import zipfile
from pathlib import Path

import pytest
from docx import Document
from docx.enum.style import WD_STYLE_TYPE
from docx.enum.text import WD_ALIGN_PARAGRAPH
from lxml import etree
from openpyxl import Workbook, load_workbook
from pptx import Presentation
from pptx.enum.text import PP_ALIGN
from pptx.util import Inches

from core.languages import RTL_LANGUAGES, is_rtl
from services.providers.schemas import TranslationRequest, TranslationResponse
from translators.excel_translator import ExcelTranslator
from translators.pdf_translator import PDFTranslator, _shape_rtl
from translators.pptx_translator import PowerPointTranslator
from translators.word_translator import (
    CS_FONTS,
    WordTranslator,
    _font_hints_for_target,
)

W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"


class MockTranslationProvider:
    """Mock translation provider for testing."""

    def __init__(self, translations: dict = None):
        self._translations = translations or {}
        self._call_count = 0
        self._requests_received: list[TranslationRequest] = []

    def get_name(self) -> str:
        return "mock"

    def is_available(self) -> bool:
        return True

    def translate_text(self, request: TranslationRequest) -> TranslationResponse:
        self._call_count += 1
        self._requests_received.append(request)

        text = request.text
        translated = self._translations.get(text, f"TR_{text}")

        return TranslationResponse(
            translated_text=translated,
            provider_name="mock",
            source_language=request.source_language,
        )

    def translate_batch(
        self, requests: list[TranslationRequest]
    ) -> list[TranslationResponse]:
        return [self.translate_text(req) for req in requests]


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _read_zip_entry(path: Path, name: str) -> bytes:
    with zipfile.ZipFile(path) as zf:
        return zf.read(name)


def _rewrite_zip_entry(path: Path, name: str, data: bytes) -> None:
    with zipfile.ZipFile(path) as zin:
        items = {n: zin.read(n) for n in zin.namelist()}
    items[name] = data
    with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zout:
        for entry_name, entry_data in items.items():
            zout.writestr(entry_name, entry_data)


_FOOTNOTES_PART = b"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
  <w:footnote w:type="separator" w:id="-1">
    <w:p><w:r><w:separator/></w:r></w:p>
  </w:footnote>
  <w:footnote w:type="continuationSeparator" w:id="0">
    <w:p><w:r><w:continuationSeparator/></w:r></w:p>
  </w:footnote>
  <w:footnote w:id="1">
    <w:p><w:r><w:t>Footnote content</w:t></w:r></w:p>
  </w:footnote>
</w:footnotes>"""

_ENDNOTES_PART = b"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:endnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
  <w:endnote w:type="separator" w:id="-1">
    <w:p><w:r><w:separator/></w:r></w:p>
  </w:endnote>
  <w:endnote w:id="1">
    <w:p><w:r><w:t>Endnote content</w:t></w:r></w:p>
  </w:endnote>
  <w:endnote w:id="2">
    <w:tbl>
      <w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr>
      <w:tr><w:tc><w:p><w:r><w:t>Endnote cell</w:t></w:r></w:p></w:tc></w:tr>
    </w:tbl>
  </w:endnote>
</w:endnotes>"""

_COMMENTS_PART = b"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
  <w:comment w:id="1" w:author="Reviewer" w:date="2026-01-01T00:00:00Z" w:initials="RV">
    <w:p><w:r><w:t>Comment content</w:t></w:r></w:p>
  </w:comment>
  <w:comment w:id="2" w:author="Reviewer" w:date="2026-01-01T00:00:00Z" w:initials="RV">
    <w:tbl>
      <w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr>
      <w:tr><w:tc><w:p><w:r><w:t>Comment cell</w:t></w:r></w:p></w:tc></w:tr>
    </w:tbl>
  </w:comment>
</w:comments>"""


def _inject_part(docx_path: Path, kind: str, xml_bytes: bytes, rel_id: str) -> None:
    """Add a minimal word/<kind>.xml part (footnotes/endnotes/comments)
    to a saved .docx, with its content-type override and relationship."""
    part_name = f"word/{kind}.xml"
    content_type = (
        "application/vnd.openxmlformats-officedocument.wordprocessingml."
        f"{kind}+xml"
    )
    ct = _read_zip_entry(docx_path, "[Content_Types].xml").decode("utf-8")
    if f"{kind}+xml" not in ct:
        ct = ct.replace(
            "</Types>",
            f'<Override PartName="/{part_name}" '
            f'ContentType="{content_type}"/></Types>',
        )
    rels = _read_zip_entry(docx_path, "word/_rels/document.xml.rels").decode("utf-8")
    if part_name not in rels:
        rels = rels.replace(
            "</Relationships>",
            f'<Relationship Id="{rel_id}" Type="http://schemas.openxmlformats.org'
            f'/officeDocument/2006/relationships/{kind}" '
            f'Target="{kind}.xml"/></Relationships>',
        )
    _rewrite_zip_entry(docx_path, "[Content_Types].xml", ct.encode("utf-8"))
    _rewrite_zip_entry(docx_path, "word/_rels/document.xml.rels", rels.encode("utf-8"))
    _rewrite_zip_entry(docx_path, part_name, xml_bytes)


def _inject_footnotes_part(docx_path: Path) -> None:
    """Add a minimal word/footnotes.xml part to a saved .docx."""
    _inject_part(docx_path, "footnotes", _FOOTNOTES_PART, "rIdRtlFootnotes")


def _inject_textbox(docx_path: Path) -> None:
    """Add a VML text box (w:txbxContent) to the document body."""
    doc_xml = _read_zip_entry(docx_path, "word/document.xml").decode("utf-8")
    textbox = (
        '<w:p><w:r><w:pict>'
        '<v:shape id="rtlTb1" style="width:100pt;height:20pt">'
        "<v:textbox><w:txbxContent>"
        "<w:p><w:r><w:t>TextBox content</w:t></w:r></w:p>"
        "</w:txbxContent></v:textbox></v:shape>"
        "</w:pict></w:r></w:p>"
    )
    # The sectPr must stay the LAST child of w:body.
    if "<w:sectPr" in doc_xml:
        doc_xml = doc_xml.replace("<w:sectPr", textbox + "<w:sectPr", 1)
    else:
        doc_xml = doc_xml.replace("</w:body>", textbox + "</w:body>")
    _rewrite_zip_entry(docx_path, "word/document.xml", doc_xml.encode("utf-8"))


def _word_paragraph_has_bidi(p_elem) -> bool:
    pPr = p_elem.find(f"{{{W_NS}}}pPr")
    return pPr is not None and pPr.find(f"{{{W_NS}}}bidi") is not None


def _find_pptx_paragraph(prs, needle: str):
    """First paragraph (slides + notes) whose text contains needle."""
    for slide in prs.slides:
        for shape in slide.shapes:
            if shape.has_text_frame:
                for para in shape.text_frame.paragraphs:
                    if needle in para.text:
                        return para
        if slide.has_notes_slide and slide.notes_slide.notes_text_frame is not None:
            for para in slide.notes_slide.notes_text_frame.paragraphs:
                if needle in para.text:
                    return para
    return None


def _host_rtl_font() -> str:
    """An Arabic-script font actually installed on this host.

    Searches ONLY the dedicated Arabic list — not the generic fallback —
    so the skipif really skips on hosts without an Arabic font.
    """
    translator = PDFTranslator()
    for p in translator._RTL_ARABIC_FONT_SEARCH_PATHS:
        if Path(p).exists():
            return p
    return None


# ---------------------------------------------------------------------------
# is_rtl() — single source of truth
# ---------------------------------------------------------------------------


class TestIsRtl:
    def test_regional_prefix_normalized(self):
        assert is_rtl("fa-IR") is True
        assert is_rtl("ar-EG") is True
        assert is_rtl("he-IL") is True
        assert is_rtl("ckb") is True

    def test_case_insensitive(self):
        assert is_rtl("AR") is True
        assert is_rtl("Fa") is True

    def test_non_rtl_targets(self):
        assert is_rtl("fr") is False
        assert is_rtl("en") is False
        assert is_rtl("zh-CN") is False
        assert is_rtl("") is False
        assert is_rtl(None) is False

    def test_full_set_is_rtl(self):
        for code in RTL_LANGUAGES:
            assert is_rtl(code) is True

    def test_rtl_set_exists_only_in_core(self):
        """RTL_LANGUAGES must not be duplicated in the translators."""
        import translators.excel_translator as xl
        import translators.pdf_translator as pdft
        import translators.pptx_translator as pt
        import translators.word_translator as wt

        for module in (wt, pt, pdft, xl):
            assert not hasattr(module, "RTL_LANGUAGES"), module.__name__


# ---------------------------------------------------------------------------
# Word — body, table, footnote, text box, section
# ---------------------------------------------------------------------------


class TestWordRtlLayout:
    def test_fa_target_full_rtl_coverage(self, tmp_path):
        """bidi on body/centered title/header, bidiVisual on the table,
        bidi on the footnote and text box, bidi on sectPr."""
        provider = MockTranslationProvider(
            {
                "Body text": "FA_Body",
                "Centered title": "FA_Title",
                "Header text": "FA_Header",
                "Cell": "FA_Cell",
                "TextBox content": "FA_Box",
                "Footnote content": "FA_Note",
            }
        )
        translator = WordTranslator(provider=provider)

        doc = Document()
        doc.add_paragraph("Body text")
        centered = doc.add_paragraph()
        centered.alignment = WD_ALIGN_PARAGRAPH.CENTER
        centered.add_run("Centered title")
        table = doc.add_table(rows=1, cols=2)
        table.cell(0, 0).text = "Cell"
        doc.sections[0].header.paragraphs[0].text = "Header text"

        input_file = tmp_path / "input.docx"
        doc.save(input_file)
        _inject_textbox(input_file)
        _inject_footnotes_part(input_file)

        output_file = tmp_path / "output.docx"
        translator.translate_file(input_file, output_file, "fa")

        doc_out = Document(output_file)

        # Body paragraph: w:bidi + run-level w:rtl
        body_para = doc_out.paragraphs[0]
        assert _word_paragraph_has_bidi(body_para._p)
        assert "FA_Body" in body_para.text

        # Centered title keeps its explicit alignment (never overwritten)
        centered_out = doc_out.paragraphs[1]
        assert _word_paragraph_has_bidi(centered_out._p)
        assert centered_out.alignment == WD_ALIGN_PARAGRAPH.CENTER
        jc = centered_out._p.find(f"{{{W_NS}}}pPr").find(f"{{{W_NS}}}jc")
        assert jc.get(f"{{{W_NS}}}val") == "center"

        # OOXML schema order: w:bidi must precede spacing/ind/jc/rPr
        # (out-of-order pPr children make Word flag the file for repair)
        centered_pPr = centered_out._p.find(f"{{{W_NS}}}pPr")
        child_tags = [child.tag for child in centered_pPr]
        bidi_pos = child_tags.index(f"{{{W_NS}}}bidi")
        for later in ("w:spacing", "w:ind", "w:jc", "w:rPr"):
            tag = f"{{{W_NS}}}{later}"
            if tag in child_tags:
                assert bidi_pos < child_tags.index(tag), later
        # Same for the run-level marker: w:rtl after w:rFonts
        body_rPr = body_para.runs[0]._r.find(f"{{{W_NS}}}rPr")
        assert body_rPr is not None
        run_tags = [child.tag for child in body_rPr]
        if f"{{{W_NS}}}rFonts" in run_tags:
            assert run_tags.index(f"{{{W_NS}}}rFonts") < run_tags.index(
                f"{{{W_NS}}}rtl"
            )

        # Table: w:bidiVisual (column order flipped visually)
        tblPr = doc_out.tables[0]._tbl.find(f"{{{W_NS}}}tblPr")
        assert tblPr is not None
        assert tblPr.find(f"{{{W_NS}}}bidiVisual") is not None
        # Schema order: w:bidiVisual after tblStyle, before tblW/tblLook
        tbl_tags = [child.tag for child in tblPr]
        if f"{{{W_NS}}}tblStyle" in tbl_tags:
            assert tbl_tags.index(f"{{{W_NS}}}tblStyle") < tbl_tags.index(
                f"{{{W_NS}}}bidiVisual"
            )
        for later in ("w:tblW", "w:tblLook"):
            tag = f"{{{W_NS}}}{later}"
            if tag in tbl_tags:
                assert tbl_tags.index(f"{{{W_NS}}}bidiVisual") < tbl_tags.index(
                    tag
                ), later

        # Section: w:bidi (page layout direction)
        assert doc_out.sections[0]._sectPr.find(f"{{{W_NS}}}bidi") is not None

        # Header paragraph: w:bidi
        header_para = doc_out.sections[0].header.paragraphs[0]
        assert _word_paragraph_has_bidi(header_para._p)

        # Text box: w:bidi on the paragraph inside w:txbxContent
        doc_root = etree.fromstring(_read_zip_entry(output_file, "word/document.xml"))
        txbx = doc_root.find(f".//{{{W_NS}}}txbxContent")
        assert txbx is not None, "text box part missing from output"
        box_para = txbx.find(f"{{{W_NS}}}p")
        assert _word_paragraph_has_bidi(box_para)

        # Footnote: w:bidi on every paragraph of footnotes.xml, w:rtl on
        # every text run, and the translated text made it through the
        # ZIP write-back.
        foot_root = etree.fromstring(
            _read_zip_entry(output_file, "word/footnotes.xml")
        )
        foot_paras = list(foot_root.iter(f"{{{W_NS}}}p"))
        assert foot_paras, "footnotes.xml has no paragraphs"
        for p in foot_paras:
            assert _word_paragraph_has_bidi(p)
        for r in foot_root.iter(f"{{{W_NS}}}r"):
            if r.find(f"{{{W_NS}}}t") is None:
                continue
            rPr = r.find(f"{{{W_NS}}}rPr")
            assert rPr is not None, "footnote run has no rPr"
            assert rPr.find(f"{{{W_NS}}}rtl") is not None, "footnote run has no w:rtl"
        foot_text = "".join(
            t.text or "" for t in foot_root.iter(f"{{{W_NS}}}t")
        )
        assert "FA_Note" in foot_text

    def test_fa_target_endnotes_and_comments_rtl(self, tmp_path):
        """Endnotes and comments (separate ZIP parts) receive w:bidi on
        their paragraphs, w:rtl on their runs and w:bidiVisual on their
        tables when the target is RTL."""
        provider = MockTranslationProvider(
            {
                "Body text": "FA_Body",
                "Endnote content": "FA_Endnote",
                "Endnote cell": "FA_EndCell",
                "Comment content": "FA_Comment",
                "Comment cell": "FA_ComCell",
            }
        )
        translator = WordTranslator(provider=provider)

        doc = Document()
        doc.add_paragraph("Body text")
        input_file = tmp_path / "input.docx"
        doc.save(input_file)
        _inject_part(input_file, "endnotes", _ENDNOTES_PART, "rIdRtlEndnotes")
        _inject_part(input_file, "comments", _COMMENTS_PART, "rIdRtlComments")

        output_file = tmp_path / "output.docx"
        translator.translate_file(input_file, output_file, "fa")

        for part_name, marker in (
            ("word/endnotes.xml", "FA_Endnote"),
            ("word/comments.xml", "FA_Comment"),
        ):
            root = etree.fromstring(_read_zip_entry(output_file, part_name))

            # Every paragraph: w:bidi
            paras = list(root.iter(f"{{{W_NS}}}p"))
            assert paras, f"{part_name} has no paragraphs"
            for p in paras:
                assert _word_paragraph_has_bidi(p), part_name

            # Every text run: w:rPr/w:rtl
            text_runs = [
                r
                for r in root.iter(f"{{{W_NS}}}r")
                if r.find(f"{{{W_NS}}}t") is not None
            ]
            assert text_runs, f"{part_name} has no text runs"
            for r in text_runs:
                rPr = r.find(f"{{{W_NS}}}rPr")
                assert rPr is not None, f"{part_name}: run has no rPr"
                assert rPr.find(f"{{{W_NS}}}rtl") is not None, part_name

            # Tables inside the part: w:bidiVisual
            tables = list(root.iter(f"{{{W_NS}}}tbl"))
            assert tables, f"{part_name} should contain a table"
            for tbl in tables:
                tblPr = tbl.find(f"{{{W_NS}}}tblPr")
                assert tblPr is not None, part_name
                assert tblPr.find(f"{{{W_NS}}}bidiVisual") is not None, part_name

            # The translation itself made it through the write-back
            part_text = "".join(
                t.text or "" for t in root.iter(f"{{{W_NS}}}t")
            )
            assert marker in part_text, part_name

    def test_fa_target_never_writes_alignment(self, tmp_path):
        """Reported bug: centered Persian text rendered on the wrong side.

        Under w:bidi, Word interprets w:jc logically: "right" means the
        END of the line — the visual LEFT for RTL. Writing jc="right"
        pushes unaligned paragraphs to the left and overrides style-level
        centering. RTL must add w:bidi (+ run w:rtl) and never touch
        w:jc: unaligned paragraphs start at the right edge, explicit
        "left" resolves to the right edge, and centering stays centering.
        """
        provider = MockTranslationProvider(
            {"Plain body": "FA1", "Styled centered title": "FA2"}
        )
        translator = WordTranslator(provider=provider)

        doc = Document()
        doc.add_paragraph("Plain body")  # no alignment anywhere
        style = doc.styles.add_style("CenteredCustom", WD_STYLE_TYPE.PARAGRAPH)
        style.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
        doc.add_paragraph("Styled centered title", style=style)  # centered via style

        input_file = tmp_path / "input.docx"
        doc.save(input_file)
        output_file = tmp_path / "output.docx"
        translator.translate_file(input_file, output_file, "fa")

        doc_out = Document(output_file)
        for para in doc_out.paragraphs:
            pPr = para._p.find(f"{{{W_NS}}}pPr")
            assert pPr is not None
            assert pPr.find(f"{{{W_NS}}}bidi") is not None
            assert pPr.find(f"{{{W_NS}}}jc") is None, "RTL must never write w:jc"

        # The style-level centering still resolves to centered
        assert (
            doc_out.paragraphs[1].style.paragraph_format.alignment
            == WD_ALIGN_PARAGRAPH.CENTER
        )
        assert "FA2" in doc_out.paragraphs[1].text

    def test_fr_target_no_rtl_attributes(self, tmp_path):
        """A non-RTL target produces no bidi/bidiVisual/rtl markup."""
        provider = MockTranslationProvider({"Body text": "FR_Body"})
        translator = WordTranslator(provider=provider)

        doc = Document()
        doc.add_paragraph("Body text")
        table = doc.add_table(rows=1, cols=2)
        table.cell(0, 0).text = "Cell"

        input_file = tmp_path / "input.docx"
        output_file = tmp_path / "output.docx"
        doc.save(input_file)
        _inject_footnotes_part(input_file)
        _inject_part(input_file, "endnotes", _ENDNOTES_PART, "rIdRtlEndnotes")
        _inject_part(input_file, "comments", _COMMENTS_PART, "rIdRtlComments")

        translator.translate_file(input_file, output_file, "fr")

        doc_xml = _read_zip_entry(output_file, "word/document.xml").decode("utf-8")
        assert "w:bidi" not in doc_xml  # also covers bidiVisual
        assert "w:rtl" not in doc_xml

        for part_name in (
            "word/footnotes.xml",
            "word/endnotes.xml",
            "word/comments.xml",
        ):
            part_xml = _read_zip_entry(output_file, part_name).decode("utf-8")
            assert "w:bidi" not in part_xml, part_name
            assert "w:rtl" not in part_xml, part_name


# ---------------------------------------------------------------------------
# Word — complex-script font hints (CS_FONTS coverage)
# ---------------------------------------------------------------------------


class TestWordCsFontHints:
    @pytest.mark.parametrize("code", sorted(CS_FONTS))
    def test_every_cs_language_gets_arial_hint(self, code):
        """Each complex-script target must resolve to an Arial cs hint —
        pinning every CS_FONTS entry (dropping one must fail here)."""
        eastasia, cs = _font_hints_for_target(code)
        assert cs == "Arial", code

    def test_cs_fonts_covers_every_rtl_language(self):
        """The CS hint map must cover the full RTL language set, not just
        the original four entries."""
        assert set(CS_FONTS) >= set(RTL_LANGUAGES)


# ---------------------------------------------------------------------------
# PowerPoint — rtl attr, alignment policy, notes, cs font hint
# ---------------------------------------------------------------------------


class TestPptxRtlLayout:
    def _build_single_text_pptx(self, tmp_path, text, alignment=None):
        prs = Presentation()
        slide = prs.slides.add_slide(prs.slide_layouts[0])
        textbox = slide.shapes.add_textbox(
            Inches(1), Inches(1), Inches(6), Inches(1)
        )
        para = textbox.text_frame.paragraphs[0]
        para.text = text
        if alignment is not None:
            para.alignment = alignment
        input_file = tmp_path / "input.pptx"
        prs.save(str(input_file))
        return input_file

    def test_centered_title_keeps_alignment(self, tmp_path):
        provider = MockTranslationProvider({"Centered title": "AR_Title"})
        translator = PowerPointTranslator(provider=provider)
        input_file = self._build_single_text_pptx(
            tmp_path, "Centered title", PP_ALIGN.CENTER
        )
        output_file = tmp_path / "out.pptx"
        translator.translate_file(input_file, output_file, "ar")

        prs_out = Presentation(str(output_file))
        para = _find_pptx_paragraph(prs_out, "AR_Title")
        assert para is not None
        pPr = para._p.find(f"{{{A_NS}}}pPr")
        assert pPr is not None
        assert pPr.get("rtl") == "1"
        assert pPr.get("algn") == "ctr", "centered alignment must be preserved"

    def test_justified_title_keeps_alignment(self, tmp_path):
        provider = MockTranslationProvider({"Justified text": "AR_Just"})
        translator = PowerPointTranslator(provider=provider)
        input_file = self._build_single_text_pptx(
            tmp_path, "Justified text", PP_ALIGN.JUSTIFY
        )
        output_file = tmp_path / "out.pptx"
        translator.translate_file(input_file, output_file, "ar")

        para = _find_pptx_paragraph(Presentation(str(output_file)), "AR_Just")
        assert para is not None
        pPr = para._p.find(f"{{{A_NS}}}pPr")
        assert pPr is not None
        assert pPr.get("rtl") == "1"
        assert pPr.get("algn") == "just", "justified alignment must be preserved"

    def test_alignment_absent_inherits_from_layout(self, tmp_path):
        """Reported bug: centered Persian text rendered on the wrong side.

        When algn is absent the paragraph inherits its alignment from
        the layout/master (often centered titles); writing algn="r"
        overrides that inheritance. rtl="1" alone already renders the
        paragraph from its start edge (right), so algn stays untouched.
        """
        provider = MockTranslationProvider({"Plain text": "AR_Plain"})
        translator = PowerPointTranslator(provider=provider)
        input_file = self._build_single_text_pptx(tmp_path, "Plain text")
        output_file = tmp_path / "out.pptx"
        translator.translate_file(input_file, output_file, "ar")

        para = _find_pptx_paragraph(Presentation(str(output_file)), "AR_Plain")
        assert para is not None
        pPr = para._p.find(f"{{{A_NS}}}pPr")
        assert pPr is not None
        assert pPr.get("rtl") == "1"
        assert pPr.get("algn") is None, "inherited alignment must not be overridden"

    def test_alignment_left_becomes_right(self, tmp_path):
        provider = MockTranslationProvider({"Left text": "AR_Left"})
        translator = PowerPointTranslator(provider=provider)
        input_file = self._build_single_text_pptx(
            tmp_path, "Left text", PP_ALIGN.LEFT
        )
        output_file = tmp_path / "out.pptx"
        translator.translate_file(input_file, output_file, "ar")

        para = _find_pptx_paragraph(Presentation(str(output_file)), "AR_Left")
        assert para is not None
        pPr = para._p.find(f"{{{A_NS}}}pPr")
        assert pPr.get("algn") == "r"

    def test_speaker_notes_rtl(self, tmp_path):
        provider = MockTranslationProvider({"Speaker notes": "AR_Notes"})
        translator = PowerPointTranslator(provider=provider)

        prs = Presentation()
        slide = prs.slides.add_slide(prs.slide_layouts[0])
        slide.notes_slide.notes_text_frame.text = "Speaker notes"
        slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))

        input_file = tmp_path / "input.pptx"
        output_file = tmp_path / "out.pptx"
        prs.save(str(input_file))
        translator.translate_file(input_file, output_file, "ar")

        prs_out = Presentation(str(output_file))
        slide_out = prs_out.slides[0]
        assert slide_out.has_notes_slide
        notes_para = slide_out.notes_slide.notes_text_frame.paragraphs[0]
        assert "AR_Notes" in notes_para.text
        pPr = notes_para._p.find(f"{{{A_NS}}}pPr")
        assert pPr is not None
        assert pPr.get("rtl") == "1"
        # Notes runs get the <a:cs> complex-script hint too
        for run in notes_para.runs:
            rPr = run._r.find(f"{{{A_NS}}}rPr")
            assert rPr is not None, "rPr missing on notes run"
            cs = rPr.find(f"{{{A_NS}}}cs")
            assert cs is not None, "<a:cs> hint missing on notes run"
            assert cs.get("typeface") == "Arial"

    def test_cs_font_hint_for_rtl_target(self, tmp_path):
        provider = MockTranslationProvider({"Hello": "AR_Hello"})
        translator = PowerPointTranslator(provider=provider)
        input_file = self._build_single_text_pptx(tmp_path, "Hello")
        output_file = tmp_path / "out.pptx"
        translator.translate_file(input_file, output_file, "ar")

        para = _find_pptx_paragraph(Presentation(str(output_file)), "AR_Hello")
        assert para is not None
        for run in para.runs:
            rPr = run._r.find(f"{{{A_NS}}}rPr")
            assert rPr is not None, "rPr missing on run"
            cs = rPr.find(f"{{{A_NS}}}cs")
            assert cs is not None, "<a:cs> hint missing on run"
            assert cs.get("typeface") == "Arial"

    def test_fr_target_no_rtl_attributes(self, tmp_path):
        provider = MockTranslationProvider({"Hello": "FR_Hello"})
        translator = PowerPointTranslator(provider=provider)
        input_file = self._build_single_text_pptx(tmp_path, "Hello")
        output_file = tmp_path / "out.pptx"
        translator.translate_file(input_file, output_file, "fr")

        prs_out = Presentation(str(output_file))
        para = _find_pptx_paragraph(prs_out, "FR_Hello")
        assert para is not None
        pPr = para._p.find(f"{{{A_NS}}}pPr")
        assert pPr is None or pPr.get("rtl") is None
        for run in para.runs:
            rPr = run._r.find(f"{{{A_NS}}}rPr")
            if rPr is not None:
                assert rPr.find(f"{{{A_NS}}}cs") is None


# ---------------------------------------------------------------------------
# Excel — sheet view direction
# ---------------------------------------------------------------------------


class TestExcelRtlLayout:
    def _build_workbook(self, tmp_path):
        wb = Workbook()
        wb.active["A1"] = "Alpha"
        wb.create_sheet("Data2")["A1"] = "Beta"
        wb.create_sheet("Data3")["A1"] = "Gamma"
        input_file = tmp_path / "input.xlsx"
        wb.save(input_file)
        return input_file

    def test_he_target_all_sheets_right_to_left(self, tmp_path):
        provider = MockTranslationProvider(
            {"Alpha": "HE_Alpha", "Beta": "HE_Beta", "Gamma": "HE_Gamma"}
        )
        translator = ExcelTranslator(provider=provider)
        input_file = self._build_workbook(tmp_path)
        output_file = tmp_path / "out.xlsx"
        translator.translate_file(input_file, output_file, "he")

        out = load_workbook(output_file)
        assert len(out.sheetnames) == 3
        for name in out.sheetnames:
            assert out[name].sheet_view.rightToLeft is True, name

    def test_fr_target_sheets_unchanged(self, tmp_path):
        provider = MockTranslationProvider(
            {"Alpha": "FR_Alpha", "Beta": "FR_Beta", "Gamma": "FR_Gamma"}
        )
        translator = ExcelTranslator(provider=provider)
        input_file = self._build_workbook(tmp_path)
        output_file = tmp_path / "out.xlsx"
        translator.translate_file(input_file, output_file, "fr")

        out = load_workbook(output_file)
        for name in out.sheetnames:
            assert not out[name].sheet_view.rightToLeft, name

    def test_chartsheet_does_not_break_rtl_translation(self, tmp_path):
        """A workbook containing a chartsheet (no sheet_view, no cell grid)
        must still translate, flip the worksheet views, and keep the
        chartsheet intact in the output."""
        from openpyxl.chart import BarChart, Reference
        from openpyxl.chartsheet import Chartsheet

        provider = MockTranslationProvider({"Alpha": "HE_Alpha"})
        translator = ExcelTranslator(provider=provider)

        wb = Workbook()
        wb.active["A1"] = "Alpha"
        data_sheet = wb.create_sheet("Data2")
        data_sheet["B2"] = 42
        chart = BarChart()
        chart.add_data(Reference(data_sheet, min_col=2, min_row=2, max_row=5))
        wb.create_chartsheet("ChartSheet").add_chart(chart)

        input_file = tmp_path / "input.xlsx"
        wb.save(input_file)
        output_file = tmp_path / "out.xlsx"

        # Must not raise (the chartsheet has neither sheet_view nor cells)
        translator.translate_file(input_file, output_file, "he")

        out = load_workbook(output_file)
        chartsheets = [
            name for name in out.sheetnames if isinstance(out[name], Chartsheet)
        ]
        assert chartsheets, "chartsheet lost during translation"
        worksheets = [
            name for name in out.sheetnames if not isinstance(out[name], Chartsheet)
        ]
        assert worksheets, "no worksheet found in output"
        for name in worksheets:
            assert out[name].sheet_view.rightToLeft is True, name


# ---------------------------------------------------------------------------
# PDF — shaping, font selection, clean-PDF mode
# ---------------------------------------------------------------------------


class TestPdfRtlShaping:
    def test_shape_rtl_produces_presentation_forms(self):
        shaped = _shape_rtl("سلام")
        assert shaped != "سلام"
        assert any(0xFB50 <= ord(c) <= 0xFEFF for c in shaped)

    def test_shape_rtl_latin_passthrough(self):
        assert _shape_rtl("Hello world") == "Hello world"

    def test_shape_rtl_empty(self):
        assert _shape_rtl("") == ""

    def test_shape_rtl_missing_libraries_fallback(self, monkeypatch):
        """Missing arabic-reshaper / python-bidi must degrade to the raw
        text (warning logged), never raise."""
        import translators.pdf_translator as pdf_mod

        # Reset the once-only warning flag so this test neither depends
        # on nor leaks into the execution order of other tests.
        monkeypatch.setattr(pdf_mod, "_RTL_SHAPING_MISSING_WARNED", False)
        monkeypatch.setitem(sys.modules, "arabic_reshaper", None)
        monkeypatch.setitem(sys.modules, "bidi", None)
        monkeypatch.setitem(sys.modules, "bidi.algorithm", None)
        text = "سلام دنیا"
        assert _shape_rtl(text) == text


class _FakePage:
    """Minimal fitz.Page double: records every insert_textbox call."""

    def __init__(self):
        import fitz

        self._fitz = fitz
        self.rect = fitz.Rect(0, 0, 612, 792)
        self.calls = []

    def insert_textbox(self, rect, text, **kwargs):
        self.calls.append({"text": text, **kwargs})
        return 0  # text fits


class TestPdfLayoutModeRtl:
    def _block(self, translated):
        return {
            "bbox": (72, 72, 300, 100),
            "text": "orig",
            "translated": translated,
            "font_size": 12.0,
            "color": 0,
            "is_bold": False,
            "is_italic": False,
        }

    def test_rtl_block_is_shaped_and_right_aligned(self):
        import fitz

        translator = PDFTranslator()
        page = _FakePage()
        ok = translator._write_translated_block(
            page, self._block("سلام دنیا"), font_path=None, rtl_target=True
        )
        assert ok is True
        assert page.calls, "no insert_textbox call recorded"
        call = page.calls[0]
        assert call["text"] != "سلام دنیا", "Arabic text must be shaped"
        assert any(0xFB50 <= ord(c) <= 0xFEFF for c in call["text"])
        assert call["align"] == fitz.TEXT_ALIGN_RIGHT

    def test_ltr_block_is_not_shaped(self):
        translator = PDFTranslator()
        page = _FakePage()
        ok = translator._write_translated_block(
            page, self._block("Bonjour le monde"), font_path=None, rtl_target=False
        )
        assert ok is True
        assert page.calls[0]["text"] == "Bonjour le monde"

    def test_ltr_target_is_left_aligned(self):
        """Regression guard: a Latin target (e.g. Persian → French) must be
        left-aligned. A previous rename once made the alignment test the
        truthiness of the imported is_rtl *function* — always true — and
        every translated PDF came out right-aligned."""
        import fitz

        translator = PDFTranslator()
        page = _FakePage()
        ok = translator._write_translated_block(
            page, self._block("Bonjour le monde"), font_path=None, rtl_target=False
        )
        assert ok is True
        assert page.calls[0]["align"] == fitz.TEXT_ALIGN_LEFT


class TestPdfFontSelection:
    def test_arabic_script_target_prefers_arabic_font(self, tmp_path, monkeypatch):
        fake_arabic = tmp_path / "NotoNaskhArabic-Regular.ttf"
        fake_arabic.write_bytes(b"fake")
        fake_hebrew = tmp_path / "NotoSansHebrew-Regular.ttf"
        fake_hebrew.write_bytes(b"fake")
        fake_generic = tmp_path / "NotoSans-Regular.ttf"
        fake_generic.write_bytes(b"fake")

        translator = PDFTranslator()
        monkeypatch.setattr(
            translator, "_RTL_ARABIC_FONT_SEARCH_PATHS", [str(fake_arabic)]
        )
        monkeypatch.setattr(
            translator, "_RTL_HEBREW_FONT_SEARCH_PATHS", [str(fake_hebrew)]
        )
        monkeypatch.setattr(translator, "_FONT_SEARCH_PATHS", [str(fake_generic)])
        assert translator._get_font_path("fa") == str(fake_arabic)
        assert translator._get_font_path("ar-EG") == str(fake_arabic)
        assert translator._get_font_path("ckb") == str(fake_arabic)

    def test_hebrew_target_prefers_hebrew_font(self, tmp_path, monkeypatch):
        """A Hebrew target must NOT pick the Arabic font (tofu) — it
        searches the Hebrew list first."""
        fake_arabic = tmp_path / "NotoNaskhArabic-Regular.ttf"
        fake_arabic.write_bytes(b"fake")
        fake_hebrew = tmp_path / "NotoSansHebrew-Regular.ttf"
        fake_hebrew.write_bytes(b"fake")
        fake_generic = tmp_path / "NotoSans-Regular.ttf"
        fake_generic.write_bytes(b"fake")

        translator = PDFTranslator()
        monkeypatch.setattr(
            translator, "_RTL_ARABIC_FONT_SEARCH_PATHS", [str(fake_arabic)]
        )
        monkeypatch.setattr(
            translator, "_RTL_HEBREW_FONT_SEARCH_PATHS", [str(fake_hebrew)]
        )
        monkeypatch.setattr(translator, "_FONT_SEARCH_PATHS", [str(fake_generic)])
        assert translator._get_font_path("he") == str(fake_hebrew)
        assert translator._get_font_path("yi") == str(fake_hebrew)

    def test_hebrew_target_falls_back_to_generic_font(self, tmp_path, monkeypatch):
        fake_generic = tmp_path / "NotoSans-Regular.ttf"
        fake_generic.write_bytes(b"fake")

        translator = PDFTranslator()
        monkeypatch.setattr(translator, "_RTL_ARABIC_FONT_SEARCH_PATHS", [])
        monkeypatch.setattr(translator, "_RTL_HEBREW_FONT_SEARCH_PATHS", [])
        monkeypatch.setattr(translator, "_FONT_SEARCH_PATHS", [str(fake_generic)])
        # Documented degradation: generic font + warning, never a failure.
        assert translator._get_font_path("he") == str(fake_generic)

    def test_arabic_target_falls_back_to_generic_font(self, tmp_path, monkeypatch):
        fake_generic = tmp_path / "NotoSans-Regular.ttf"
        fake_generic.write_bytes(b"fake")

        translator = PDFTranslator()
        monkeypatch.setattr(translator, "_RTL_ARABIC_FONT_SEARCH_PATHS", [])
        monkeypatch.setattr(translator, "_RTL_HEBREW_FONT_SEARCH_PATHS", [])
        monkeypatch.setattr(translator, "_FONT_SEARCH_PATHS", [str(fake_generic)])
        assert translator._get_font_path("ar") == str(fake_generic)

    def test_thana_target_uses_generic_list_directly(self, tmp_path, monkeypatch):
        """dv (Thaana) has no dedicated font list — generic list directly,
        never the Arabic/Hebrew ones."""
        fake_arabic = tmp_path / "NotoNaskhArabic-Regular.ttf"
        fake_arabic.write_bytes(b"fake")
        fake_hebrew = tmp_path / "NotoSansHebrew-Regular.ttf"
        fake_hebrew.write_bytes(b"fake")
        fake_generic = tmp_path / "NotoSans-Regular.ttf"
        fake_generic.write_bytes(b"fake")

        translator = PDFTranslator()
        monkeypatch.setattr(
            translator, "_RTL_ARABIC_FONT_SEARCH_PATHS", [str(fake_arabic)]
        )
        monkeypatch.setattr(
            translator, "_RTL_HEBREW_FONT_SEARCH_PATHS", [str(fake_hebrew)]
        )
        monkeypatch.setattr(translator, "_FONT_SEARCH_PATHS", [str(fake_generic)])
        assert translator._get_font_path("dv") == str(fake_generic)

    def test_non_rtl_target_ignores_rtl_only_fonts(self, tmp_path, monkeypatch):
        fake_arabic = tmp_path / "NotoNaskhArabic-Regular.ttf"
        fake_arabic.write_bytes(b"fake")

        translator = PDFTranslator()
        monkeypatch.setattr(
            translator, "_RTL_ARABIC_FONT_SEARCH_PATHS", [str(fake_arabic)]
        )
        monkeypatch.setattr(translator, "_FONT_SEARCH_PATHS", [])
        assert translator._get_font_path("fr") is None


class TestPdfFontRegistration:
    def test_register_rtl_font_names_unique_per_path(self, tmp_path, monkeypatch):
        """Two different fonts (Arabic then Hebrew) must get distinct
        reportlab names; the same path registers only once."""
        import reportlab.pdfbase.pdfmetrics as pdfmetrics_mod
        import reportlab.pdfbase.ttfonts as ttf_mod

        import translators.pdf_translator as pdf_mod

        registered = []

        class FakeTTFont:
            def __init__(self, name, path):
                self.fontName = name
                registered.append((name, path))

        # Only the naming/registry logic is under test here — neutralize
        # reportlab's own bookkeeping.
        monkeypatch.setattr(ttf_mod, "TTFont", FakeTTFont)
        monkeypatch.setattr(pdfmetrics_mod, "registerFont", lambda font: None)
        monkeypatch.setattr(pdf_mod, "_RTL_FONT_REGISTRATIONS", {})

        font_a = tmp_path / "hebrew.ttf"
        font_a.write_bytes(b"x")
        font_b = tmp_path / "arabic.ttf"
        font_b.write_bytes(b"y")

        name_a1 = pdf_mod._register_rtl_font(str(font_a))
        name_a2 = pdf_mod._register_rtl_font(str(font_a))
        name_b = pdf_mod._register_rtl_font(str(font_b))

        assert name_a1 == name_a2, "same path must reuse its registration"
        assert name_a1 != name_b, "different fonts must not share a name"
        assert name_a1.startswith("WordlyRTL-")
        assert len(registered) == 2, "same path must be registered once"


class TestPdfCleanModeRtl:
    def _spy_on_body_style(self, monkeypatch):
        import reportlab.lib.styles as rls

        captured = {}
        original = rls.ParagraphStyle

        class SpyStyle(original):
            def __init__(self, name, **kwargs):
                super().__init__(name, **kwargs)
                if name == "BodyText_Custom":
                    captured.update(kwargs)

        monkeypatch.setattr(rls, "ParagraphStyle", SpyStyle)
        return captured

    def test_rtl_style_wordwrap_and_alignment(self, tmp_path, monkeypatch):
        captured = self._spy_on_body_style(monkeypatch)
        monkeypatch.setattr(PDFTranslator, "_RTL_ARABIC_FONT_SEARCH_PATHS", [])
        monkeypatch.setattr(PDFTranslator, "_RTL_HEBREW_FONT_SEARCH_PATHS", [])
        monkeypatch.setattr(PDFTranslator, "_FONT_SEARCH_PATHS", [])

        from reportlab.lib.enums import TA_RIGHT

        translator = PDFTranslator()
        out = tmp_path / "clean_fa.pdf"
        translator._generate_clean_pdf(["سلام دنیا"], out, "fa")

        assert out.exists()
        assert captured.get("wordWrap") == "RTL"
        assert captured.get("alignment") == TA_RIGHT
        # No Arabic-capable font on this (fake) host → Helvetica fallback
        assert captured.get("fontName") == "Helvetica"

    def test_ltr_style_unchanged(self, tmp_path, monkeypatch):
        captured = self._spy_on_body_style(monkeypatch)

        from reportlab.lib.enums import TA_JUSTIFY

        translator = PDFTranslator()
        out = tmp_path / "clean_fr.pdf"
        translator._generate_clean_pdf(["Bonjour le monde"], out, "fr")

        assert out.exists()
        assert captured.get("wordWrap") is None
        assert captured.get("alignment") == TA_JUSTIFY
        assert captured.get("fontName") == "Helvetica"

    @pytest.mark.skipif(_host_rtl_font() is None, reason="no Arabic-capable font on this host")
    def test_rtl_real_font_registered_and_shaped(self, tmp_path):
        """End-to-end: shaped Arabic rendered with an Arabic-capable TTF."""
        translator = PDFTranslator()
        font_path = translator._get_font_path("fa")
        assert font_path is not None

        out = tmp_path / "clean_fa_real.pdf"
        translator._generate_clean_pdf(["سلام دنیا این یک آزمایش است"], out, "fa")

        import fitz

        doc = fitz.open(str(out))
        extracted = doc[0].get_text()
        doc.close()
        # Presentation forms in the output prove both the shaping and the
        # Arabic-capable font reached the PDF (Helvetica cannot encode them).
        assert any(0xFB50 <= ord(c) <= 0xFEFF for c in extracted)


# ---------------------------------------------------------------------------
# Normalisation LTR — un document source RTL traduit vers une cible latine
# (ex. persan → français) doit ressortir en lecture gauche-à-droite
# ---------------------------------------------------------------------------


class TestLtrNormalization:
    def test_word_rtl_source_latin_target_strips_rtl(self, tmp_path):
        """Persan → français : bidi/rtl/bidiVisual hérités de la source
        sont retirés (corps, section, note de bas de page, tableau)."""
        # 1) Fabrication d'un document RTL complet (cible fa)
        t_fa = WordTranslator(
            provider=MockTranslationProvider({"Hello": "FA1", "Extra": "FA2"})
        )
        doc = Document()
        doc.add_paragraph("Hello")
        table = doc.add_table(rows=1, cols=2)
        table.cell(0, 0).text = "Extra"
        fa_in = tmp_path / "in.docx"
        doc.save(fa_in)
        _inject_footnotes_part(fa_in)
        fa_out = tmp_path / "fa.docx"
        t_fa.translate_file(fa_in, fa_out, "fa")

        # Sanity : la sortie fa porte bien les marques RTL
        fa_doc = etree.fromstring(_read_zip_entry(fa_out, "word/document.xml"))
        assert fa_doc.find(f".//{{{W_NS}}}bidi") is not None
        fa_notes = etree.fromstring(_read_zip_entry(fa_out, "word/footnotes.xml"))
        assert fa_notes.find(f".//{{{W_NS}}}bidi") is not None

        # 2) Traduction de ce document RTL vers le français
        t_fr = WordTranslator(
            provider=MockTranslationProvider({"FA1": "Bonjour", "FA2": "ExtraFR"})
        )
        fr_out = tmp_path / "fr.docx"
        t_fr.translate_file(fa_out, fr_out, "fr")

        fr_doc = etree.fromstring(_read_zip_entry(fr_out, "word/document.xml"))
        assert fr_doc.find(f".//{{{W_NS}}}bidi") is None
        assert fr_doc.find(f".//{{{W_NS}}}rtl") is None
        assert fr_doc.find(f".//{{{W_NS}}}bidiVisual") is None
        fr_notes = etree.fromstring(_read_zip_entry(fr_out, "word/footnotes.xml"))
        assert fr_notes.find(f".//{{{W_NS}}}bidi") is None
        # La traduction française est bien là
        assert "Bonjour" in "".join(t.text or "" for t in fr_doc.iter(f"{{{W_NS}}}t"))

    def test_excel_rtl_source_latin_target_restores_ltr(self, tmp_path):
        wb = Workbook()
        wb.active["A1"] = "سلام"
        wb.active.sheet_view.rightToLeft = True  # feuille d'origine persane
        input_file = tmp_path / "in.xlsx"
        wb.save(input_file)

        out = tmp_path / "out.xlsx"
        ExcelTranslator(provider=MockTranslationProvider({"سلام": "Bonjour"})).translate_file(
            input_file, out, "fr"
        )
        assert not load_workbook(out).active.sheet_view.rightToLeft

    def test_pptx_rtl_source_latin_target_strips_rtl(self, tmp_path):
        prs = Presentation()
        slide = prs.slides.add_slide(prs.slide_layouts[6])
        box = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
        para = box.text_frame.paragraphs[0]
        para.text = "سلام"
        # Simule un paragraphe d'origine persane
        pPr = para._p.get_or_add_pPr()
        pPr.set("rtl", "1")
        input_file = tmp_path / "in.pptx"
        prs.save(input_file)

        out = tmp_path / "out.pptx"
        PowerPointTranslator(
            provider=MockTranslationProvider({"سلام": "Bonjour"})
        ).translate_file(input_file, out, "fr")

        out_para = _find_pptx_paragraph(Presentation(str(out)), "Bonjour")
        assert out_para is not None
        out_pPr = out_para._p.find(f"{{{A_NS}}}pPr")
        assert out_pPr is None or out_pPr.get("rtl") != "1"

    @pytest.mark.skipif(_host_rtl_font() is None, reason="no Arabic-capable font on this host")
    def test_layout_mode_uses_the_resolved_font_file(self, tmp_path):
        """Regression guard: PyMuPDF ignores the fontfile when fontname is a
        base-14 name ("helv") — every Persian/Arabic glyph used to render as
        '?' in Helvetica, with mass "[translation overflow]" markers."""
        import fitz

        src = tmp_path / "src.pdf"
        doc = fitz.open()
        page = doc.new_page()
        page.insert_textbox(
            fitz.Rect(72, 72, 400, 96), "Bonjour le monde",
            fontsize=11, fontname="helv",
        )
        doc.save(str(src))
        doc.close()

        translator = PDFTranslator(
            provider=MockTranslationProvider({"Bonjour le monde": "سلام دنیا"})
        )
        out = tmp_path / "out.pdf"
        translator.translate_file(src, out, "fa")

        doc2 = fitz.open(str(out))
        fonts = [f[3] for f in doc2[0].get_fonts()]
        text = doc2[0].get_text()
        doc2.close()

        assert "Helvetica" not in fonts, fonts
        assert "?" not in text
        assert any(0xFB50 <= ord(c) <= 0xFEFF for c in text)

    @pytest.mark.skipif(_host_rtl_font() is None, reason="no Arabic-capable font on this host")
    def test_rtl_multiline_order_preserved(self, tmp_path):
        """Multi-line RTL text must wrap lines top-to-bottom so the start
        of the paragraph appears on the first line, not upside-down."""
        import fitz

        src = tmp_path / "src_multiline.pdf"
        doc = fitz.open()
        page = doc.new_page(width=300, height=200)
        # Narrow box to force text into multiple lines
        page.insert_textbox(
            fitz.Rect(50, 50, 250, 150),
            "1. Première étape d'installation avec des instructions détaillées.",
            fontsize=12, fontname="helv",
        )
        doc.save(str(src))
        doc.close()

        fa_text = "1. مرحله اول نصب با دستورالعمل های دقیق و کامل برای ساعت هوشمند."
        translator = PDFTranslator(
            provider=MockTranslationProvider({
                "1. Première étape d'installation avec des instructions détaillées.": fa_text
            })
        )
        out = tmp_path / "out_multiline.pdf"
        translator.translate_file(src, out, "fa")

        doc2 = fitz.open(str(out))
        page_dict = doc2[0].get_text("dict")
        doc2.close()

        lines = [
            "".join(s["text"] for s in l["spans"])
            for b in page_dict["blocks"] if "lines" in b
            for l in b["lines"]
        ]
        assert len(lines) >= 2
        # The number '1.' and start of text must appear on the FIRST line
        assert "1." in lines[0] or "1" in lines[0]
        # Must not contain overflow marker
        assert "[translation overflow]" not in " ".join(lines)

    def test_pdf_multicolumn_protection(self, tmp_path):
        """Tier 1 expansion on a left column must not expand past the right column."""
        import fitz

        src = tmp_path / "src_cols.pdf"
        doc = fitz.open()
        page = doc.new_page(width=600, height=400)
        # Left column at x=50..250, right column at x=300..500
        page.insert_textbox(fitz.Rect(50, 50, 250, 100), "Titre gauche", fontsize=14, fontname="helv")
        page.insert_textbox(fitz.Rect(300, 50, 500, 100), "Titre droite", fontsize=14, fontname="helv")
        doc.save(str(src))
        doc.close()

        translator = PDFTranslator(
            provider=MockTranslationProvider({
                "Titre gauche": "A very long left heading translation that requires more width",
                "Titre droite": "Right heading",
            })
        )
        out = tmp_path / "out_cols.pdf"
        translator.translate_file(src, out, "en")

        doc2 = fitz.open(str(out))
        blocks = doc2[0].get_text("blocks")
        doc2.close()

        left_blocks = [b for b in blocks if "left heading" in b[4]]
        if left_blocks:
            # The left block must NOT cross the start of the right column (x=300)
            assert left_blocks[0][2] <= 300.0

    def test_pdf_hebrew_formatting(self):
        """Hebrew text must be visually formatted without Arabic cursive reshaping."""
        from translators.pdf_translator import _shape_rtl_multiline
        hebrew_text = "שלום עולם"
        res = _shape_rtl_multiline(hebrew_text, max_width=200, fontsize=12)
        assert res is not None
        assert len(res) > 0
