"""Shared building blocks for the PHASE 1 multi-format converters. The EPUB pipeline (app.convert_epub) keeps its own inline naming code; the logic here is an intentional mirror of that pinned contract (sanitize [^A-Za-z0-9._ -]+ -> '_', 60-char cap, no doubled .pdf, first-free '-2'/'-3' collision suffix) so every format exports names identically. No module-level import of app: app.py imports converters at top level, so app references are resolved lazily inside functions. """ from __future__ import annotations import html as _html import io import os import re from pathlib import Path __all__ = [ "PRE_CSS", "convert_single", "decode_declared", "decode_text", "derive_export_name", "escape", "finalize_pdf", "html_to_pdf_bytes", "report", ] # CSS for
-based outputs (txt, log, json, xml): monospace, wrapped.
PRE_CSS = """
@page { size: A4; margin: 18mm 16mm; }
body {
    font-family: "DejaVu Sans Mono", "Courier New", monospace;
    font-size: 9pt; line-height: 1.5; color: #1c1b1a;
    font-kerning: none; /* pypdf misreads WeasyPrint kerning as spaces */
}
pre { white-space: pre-wrap; word-wrap: break-word; margin: 0; }
"""


def escape(text: str) -> str:
    """HTML-escape for safe embedding in generated 
/ bodies."""
    return _html.escape(text, quote=False)


def default_output_dir(output_dir=None) -> Path:
    """CLI-supplied output dir (created), else the app-managed OUTPUT_DIR."""
    if output_dir:
        d = Path(output_dir)
        d.mkdir(parents=True, exist_ok=True)
        return d
    import app  # lazy: avoids a module-level import cycle
    return app.OUTPUT_DIR


def decode_declared(raw: bytes) -> str:
    """Decode honoring an XML-declaration /  encoding, else UTF-8.

    Reuses the pinned charset-detection regexes from the EPUB path so an HTML
    file with a windows-1252 declaration decodes like an EPUB chapter.
    Undecodable input never raises.
    """
    import app
    import codecs

    m = app._XML_DECL_ENC.search(raw[:4096]) or app._META_ENC.search(raw[:8192])
    if m:
        enc = m.group(1).decode("ascii", errors="replace").strip()
        try:
            codecs.lookup(enc)
            return raw.decode(enc)
        except (LookupError, UnicodeDecodeError):
            pass
    return raw.decode("utf-8", errors="replace")


def decode_text(raw: bytes) -> str:
    """Best-effort text decode: strict UTF-8, then charset-normalizer, then replace.

    Strict UTF-8 first so valid UTF-8 is never second-guessed; the
    charset-normalizer pass catches legacy encodings (windows-1252, latin-1)
    without mojibake.
    """
    try:
        return raw.decode("utf-8")
    except UnicodeDecodeError:
        pass
    from charset_normalizer import from_bytes

    best = from_bytes(raw).best()
    if best is not None:
        return str(best)
    return raw.decode("utf-8", errors="replace")


def derive_export_name(filename: str, out_dir: Path, title: str | None = None) -> str:
    """Collision-safe PDF name, mirroring the pinned EPUB export-name contract.

    base = clean(title) if title else clean(stem); empty -> 'book'.
    clean: [^A-Za-z0-9._ -]+ -> '_', [:60] cap, strip trailing '.pdf'
    (case-insensitive). Collision: first free of base.pdf, base-2.pdf, ... .
    """
    stem = Path(filename).stem
    if re.fullmatch(r"\.\w+", stem):
        # bare dotfile (".txt"): Path keeps the dot in .stem; treat as empty
        stem = ""
    if title and re.sub(r"[^A-Za-z0-9._ -]+", "_", title).strip():
        base = re.sub(r"[^A-Za-z0-9._ -]+", "_", title).strip()[:60].strip()
    else:
        base = re.sub(r"[^A-Za-z0-9._ -]+", "_", stem).strip()[:60].strip() or "book"
    if base.lower().endswith(".pdf"):
        base = base[:-4].rstrip() or "book"
    filename_out = f"{base}.pdf"
    counter = 2
    while (Path(out_dir) / filename_out).exists():
        filename_out = f"{base}-{counter}.pdf"
        counter += 1
    return filename_out


def html_to_pdf_bytes(html: str, css: str | None = None) -> bytes:
    """Render an HTML string to PDF bytes via WeasyPrint.

    css: extra stylesheet (e.g. PRE_CSS for text dumps). Empty/None means
    the document carries its own