"""PDF / DOCX / XLSX / PPTX 与图片提取为 TXT、HTML、Markdown；图片外置到 images_<源文件名> 目录。"""

from __future__ import annotations

import html
import io
import re
import shutil
from pathlib import Path
from typing import Any, Callable, Iterator

_VALID_FORMATS = frozenset({"markdown", "html", "txt"})


def _images_subdir_name(stem: str) -> str:
    return f"images_{stem}"


def _ensure_images_dir(output_dir: Path, stem: str) -> Path:
    d = output_dir / _images_subdir_name(stem)
    d.mkdir(parents=True, exist_ok=True)
    return d


def _page_index_range(
    total: int, page_start: int | None, page_end: int | None
) -> range:
    """1-based 闭区间 → 0-based range；total 为页/幻灯片总数。"""
    if total <= 0:
        return range(0, 0)
    a = 1 if page_start is None else max(1, int(page_start))
    b = total if page_end is None else int(page_end)
    b = min(total, max(a, b))
    return range(a - 1, b)


def _ocr_pdf_page(page: Any) -> tuple[str | None, str | None]:
    """返回 (文本, 错误说明)。需安装可选依赖 pytesseract 与系统 Tesseract。"""
    try:
        import pytesseract
        from PIL import Image
    except ImportError as e:
        return None, f"OCR skipped: {e} (pip install pytesseract, install Tesseract-OCR binary)"
    import fitz

    m = fitz.Matrix(2.0, 2.0)
    pix = page.get_pixmap(matrix=m, alpha=False)
    img = Image.open(io.BytesIO(pix.tobytes("png")))
    try:
        text = pytesseract.image_to_string(img, lang="eng+chi_sim")
    except Exception as e:  # noqa: BLE001
        el = repr(e).lower()
        if "tesseract" in el and ("not" in el or "path" in el or "cmd" in el):
            return None, "Tesseract executable not found (install Tesseract-OCR and PATH)"
        return None, repr(e)
    return (text or "").strip() or None, None


def _markdown_to_txt(md: str) -> str:
    t = re.sub(r"^#+\s*", "", md, flags=re.MULTILINE)
    t = re.sub(r"!\[[^\]]*\]\(([^)]+)\)", r"[Image: \1]", t)
    return t.strip()


def _markdown_subset_to_html(md: str, title: str) -> str:
    parts: list[str] = [
        "<!DOCTYPE html>",
        '<html lang="zh-CN">',
        "<head>",
        '<meta charset="utf-8"/>',
        f"<title>{html.escape(title)}</title>",
        "</head><body>",
    ]
    for block in re.split(r"\n\n+", md.strip()):
        block = block.strip()
        if not block:
            continue
        if block.startswith("## "):
            parts.append(f"<h2>{html.escape(block[3:].strip())}</h2>")
        elif block.startswith("![](") and block.endswith(")"):
            src = block[4:-1].strip()
            parts.append(
                f'<p><img src="{html.escape(src, quote=True)}" alt=""/></p>'
            )
        elif block.startswith("# "):
            parts.append(f"<h1>{html.escape(block[2:].strip())}</h1>")
        else:
            for ln in block.split("\n"):
                parts.append(f"<p>{html.escape(ln)}</p>")
    parts.append("</body></html>")
    return "\n".join(parts)


class DocumentConverter:
    def __init__(self, policy_check: Callable[[Path], None]) -> None:
        self._check = policy_check

    def convert_to_markdown(self, source: str, output_dir: str) -> dict[str, Any]:
        """兼容旧工具：等价于 doc_extract format=markdown，全页/全表。保留 markdown / assets 字段名。"""
        r = self.convert_document(
            source,
            output_dir,
            output_format="markdown",
            page_start=None,
            page_end=None,
            use_ocr=False,
            sheet_names=None,
        )
        if r.get("ok"):
            r["markdown"] = r["output_path"]
            r["assets"] = r["images_dir"]
        return r

    def convert_document(
        self,
        source: str,
        output_dir: str,
        *,
        output_format: str = "markdown",
        page_start: int | None = None,
        page_end: int | None = None,
        use_ocr: bool = False,
        sheet_names: list[str] | None = None,
    ) -> dict[str, Any]:
        fmt = (output_format or "markdown").strip().lower()
        if fmt not in _VALID_FORMATS:
            return {"ok": False, "error": f"output_format must be one of {sorted(_VALID_FORMATS)}"}

        src = Path(source).expanduser().resolve()
        out = Path(output_dir).expanduser().resolve()
        self._check(src)
        self._check(out)
        out.mkdir(parents=True, exist_ok=True)

        stem = src.stem
        images_dir = _ensure_images_dir(out, stem)
        rel_img_prefix = images_dir.name

        suf = src.suffix.lower()
        notes: list[str] = []

        if page_start is not None or page_end is not None:
            if suf not in {".pdf", ".pptx"}:
                notes.append(
                    "page_start/page_end apply to PDF and PPTX only; ignored for this type."
                )

        if suf == ".pdf":
            body = self._extract_pdf(
                src,
                images_dir,
                rel_img_prefix,
                page_start,
                page_end,
                use_ocr,
                notes,
            )
        elif suf == ".docx" and fmt == "html":
            final = self._extract_docx_html(src, images_dir, rel_img_prefix, notes)
            out_path = out / f"{stem}.html"
            out_path.write_text(final, encoding="utf-8")
            return {
                "ok": True,
                "output_path": str(out_path),
                "output_format": "html",
                "images_dir": str(images_dir),
                "images_relative_prefix": rel_img_prefix,
                "notes": notes,
            }
        elif suf == ".docx":
            try:
                body = self._extract_docx_markdown(
                    src, images_dir, rel_img_prefix, notes
                )
            except Exception as e:  # noqa: BLE001
                notes.append(
                    f"mammoth DOCX conversion failed ({type(e).__name__}: {e}); "
                    "falling back to python-docx plain text (tables as tab-separated rows; embedded images not inlined)."
                )
                body = self._extract_docx_plain_python_docx(src, notes)
        elif suf == ".xlsx":
            body = self._extract_xlsx(
                src, page_start, page_end, sheet_names, notes
            )
        elif suf == ".pptx":
            body = self._extract_pptx(
                src,
                images_dir,
                rel_img_prefix,
                page_start,
                page_end,
                notes,
            )
        elif suf in {".png", ".jpg", ".jpeg", ".gif", ".webp"}:
            body = self._extract_image_file(src, images_dir, rel_img_prefix)
        else:
            return {"ok": False, "error": f"unsupported type: {suf}"}

        if not body.startswith("#"):
            body = f"# {src.name}\n\n" + body

        ext = {"markdown": ".md", "html": ".html", "txt": ".txt"}[fmt]
        out_path = out / f"{stem}{ext}"

        if fmt == "markdown":
            final = body
        elif fmt == "txt":
            final = _markdown_to_txt(body)
        else:
            final = _markdown_subset_to_html(body, stem)

        out_path.write_text(final, encoding="utf-8")

        return {
            "ok": True,
            "output_path": str(out_path),
            "output_format": fmt,
            "images_dir": str(images_dir),
            "images_relative_prefix": rel_img_prefix,
            "notes": notes,
        }

    def _extract_pdf(
        self,
        src: Path,
        images_dir: Path,
        rel_prefix: str,
        page_start: int | None,
        page_end: int | None,
        use_ocr: bool,
        notes: list[str],
    ) -> str:
        import fitz

        doc = fitz.open(src)
        try:
            pr = _page_index_range(len(doc), page_start, page_end)
            parts: list[str] = [f"# {src.name}\n"]
            img_count = 0
            for page_index in pr:
                page = doc[page_index]
                pn = page_index + 1
                parts.append(f"## Page {pn}\n")
                text = (page.get_text("text") or "").strip()
                if use_ocr:
                    ocr_t, ocr_err = _ocr_pdf_page(page)
                    if ocr_err:
                        notes.append(f"page {pn}: {ocr_err}")
                    if ocr_t:
                        if text:
                            parts.append(text + "\n\n")
                            parts.append("### OCR\n" + ocr_t + "\n")
                        else:
                            parts.append(ocr_t + "\n")
                    elif not text:
                        parts.append("_(no text layer; OCR unavailable or empty)_\n")
                else:
                    parts.append((text or "_(no text)_") + "\n")
                for img in page.get_images(full=True):
                    xref = img[0]
                    base = doc.extract_image(xref)
                    img_count += 1
                    name = f"pdf_p{pn}_{img_count}.{base['ext']}"
                    dest = images_dir / name
                    dest.write_bytes(base["image"])
                    parts.append(f"![]({rel_prefix}/{name})\n")
            return "\n".join(parts)
        finally:
            doc.close()

    def _make_docx_image_converter(
        self, images_dir: Path, rel_prefix: str
    ) -> Callable[..., dict[str, str]]:
        counter = [0]

        def convert_image(image: Any) -> dict[str, str]:
            with image.open() as f:
                data = f.read()
            counter[0] += 1
            ct = (image.content_type or "image/png").lower()
            if "jpeg" in ct or "jpg" in ct:
                ext = "jpg"
            elif "png" in ct:
                ext = "png"
            elif "gif" in ct:
                ext = "gif"
            else:
                ext = "bin"
            name = f"docx_{counter[0]}.{ext}"
            dest = images_dir / name
            dest.write_bytes(data)
            return {"src": f"{rel_prefix}/{name}"}

        return convert_image

    def _extract_docx_plain_python_docx(self, src: Path, notes: list[str]) -> str:
        """
        Mammoth 失败或异常文档时的回退：按文档顺序抽取段落与表格为纯文本（表格行为制表符分隔）。
        不导出内嵌图片路径；复杂排版可能弱于 Mammoth。
        """
        from docx import Document
        from docx.oxml.table import CT_Tbl
        from docx.oxml.text.paragraph import CT_P
        from docx.table import Table, _Cell
        from docx.text.paragraph import Paragraph

        doc = Document(str(src))
        lines: list[str] = []

        def iter_block_items(parent: Any) -> Iterator[Any]:
            if isinstance(parent, Document):
                parent_elm = parent.element.body
            elif isinstance(parent, _Cell):
                parent_elm = parent._tc
            else:
                return
            for child in parent_elm.iterchildren():
                if isinstance(child, CT_P):
                    yield Paragraph(child, parent)
                elif isinstance(child, CT_Tbl):
                    yield Table(child, parent)

        try:
            for block in iter_block_items(doc):
                if isinstance(block, Paragraph):
                    t = (block.text or "").strip()
                    if t:
                        lines.append(t)
                elif isinstance(block, Table):
                    for row in block.rows:
                        cells = [(c.text or "").strip() for c in row.cells]
                        if any(cells):
                            lines.append("\t".join(cells))
        except Exception as e:  # noqa: BLE001
            notes.append(f"python-docx plain fallback partial error: {e!r}")
            for para in doc.paragraphs:
                t = (para.text or "").strip()
                if t:
                    lines.append(t)
            for table in doc.tables:
                for row in table.rows:
                    cells = [(c.text or "").strip() for c in row.cells]
                    if any(cells):
                        lines.append("\t".join(cells))

        text = "\n\n".join(lines).strip()
        return text if text else "_(no extractable text)_"

    def _extract_docx_markdown(
        self,
        src: Path,
        images_dir: Path,
        rel_prefix: str,
        notes: list[str],
    ) -> str:
        import mammoth

        cv = self._make_docx_image_converter(images_dir, rel_prefix)
        with src.open("rb") as f:
            result = mammoth.convert_to_markdown(f, convert_image=cv)
        notes.extend(m.message for m in result.messages)
        return result.value

    def _extract_docx_html(
        self,
        src: Path,
        images_dir: Path,
        rel_prefix: str,
        notes: list[str],
    ) -> str:
        import mammoth

        cv = self._make_docx_image_converter(images_dir, rel_prefix)
        with src.open("rb") as f:
            result = mammoth.convert_to_html(f, convert_image=cv)
        notes.extend(m.message for m in result.messages)
        return result.value

    def _extract_xlsx(
        self,
        src: Path,
        page_start: int | None,
        page_end: int | None,
        sheet_names: list[str] | None,
        notes: list[str],
    ) -> str:
        import openpyxl

        wb = openpyxl.load_workbook(src, read_only=True, data_only=True)
        try:
            names = list(wb.sheetnames)
            if sheet_names:
                sheets = [n for n in names if n in set(sheet_names)]
                if not sheets:
                    notes.append("sheet_names matched nothing; using all sheets.")
                    sheets = names
            else:
                pr = _page_index_range(len(names), page_start, page_end)
                sheets = [names[i] for i in pr]
            parts: list[str] = [f"# {src.stem}\n"]
            for sheet in sheets:
                ws = wb[sheet]
                parts.append(f"\n## {sheet}\n")
                rows_out: list[str] = []
                for row in ws.iter_rows(values_only=True):
                    cells = ["" if c is None else str(c) for c in row]
                    if any(x.strip() for x in cells):
                        rows_out.append("| " + " | ".join(cells) + " |")
                if rows_out:
                    parts.append("\n".join(rows_out))
            return "\n".join(parts)
        finally:
            wb.close()

    def _extract_pptx(
        self,
        src: Path,
        images_dir: Path,
        rel_prefix: str,
        page_start: int | None,
        page_end: int | None,
        notes: list[str],
    ) -> str:
        from pptx import Presentation
        from pptx.enum.shapes import MSO_SHAPE_TYPE

        prs = Presentation(str(src))
        nslides = len(prs.slides)
        pr = _page_index_range(nslides, page_start, page_end)
        parts: list[str] = [f"# {src.stem}\n"]
        pic_n = 0
        for si in pr:
            slide = prs.slides[si]
            sn = si + 1
            parts.append(f"\n## Slide {sn}\n")
            for shape in slide.shapes:
                if getattr(shape, "has_text_frame", False):
                    for para in shape.text_frame.paragraphs:
                        t = para.text.strip()
                        if t:
                            parts.append(t + "\n")
                if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
                    try:
                        blob = shape.image.blob
                        ext = (shape.image.ext or "png").lstrip(".")
                        pic_n += 1
                        name = f"pptx_s{sn}_{pic_n}.{ext}"
                        dest = images_dir / name
                        dest.write_bytes(blob)
                        parts.append(f"![]({rel_prefix}/{name})\n")
                    except Exception as e:  # noqa: BLE001
                        notes.append(f"slide {sn} picture: {e!r}")
        return "\n\n".join(parts)

    def _extract_image_file(
        self, src: Path, images_dir: Path, rel_prefix: str
    ) -> str:
        from PIL import Image

        dest = images_dir / src.name
        shutil.copy2(src, dest)
        with Image.open(dest) as im:
            info = {"format": im.format, "size": im.size, "mode": im.mode}
        return (
            f"# Image {src.name}\n\n"
            f"![]({rel_prefix}/{dest.name})\n\n"
            f"Metadata: `{info}`\n"
        )
