# -*- coding: utf-8 -*-
"""PowerPoint (.pptx) file parser.

Walks the deck slide-by-slide and emits one :class:`Section` per
contiguous content block — adjacent text runs (and, by default,
tables) are merged into a single text section; embedded images are
emitted as their own :class:`DataBlock` sections.  Each section's
metadata carries the slide index (starting at 1) for later citation.

Mirrors the knob set of the v1 ``PowerPointReader``:
``include_image``, ``separate_table``, ``table_format``,
``slide_prefix``, ``slide_suffix``.  Chunking is **not** done here —
long text stays intact inside a section and is split downstream by a
:class:`~agentscope.rag.ChunkerBase`.
"""
import base64
import io
from typing import Any, Literal

from ..._logging import logger
from ...message import Base64Source, DataBlock, TextBlock
from .._document import Section
from ._base import ParserBase
from ._utils import (
    _guess_image_media_type,
    _table_to_json,
    _table_to_markdown,
)


def _extract_table_rows(table: Any) -> list[list[str]]:
    """Read a python-pptx table into a 2-D ``list[list[str]]``.

    Args:
        table (`Any`):
            The python-pptx ``Table`` object.

    Returns:
        `list[list[str]]`:
            One inner list per row; per-cell line breaks are
            normalised to ``\\n``.
    """
    rows: list[list[str]] = []
    for row in table.rows:
        cells: list[str] = []
        for cell in row.cells:
            text = cell.text.strip()
            text = text.replace("\r\n", "\n").replace("\r", "\n")
            cells.append(text)
        rows.append(cells)
    return rows


def _extract_image_bytes(shape: Any) -> bytes | None:
    """Return the embedded image bytes for a picture shape, or ``None``.

    Args:
        shape (`Any`):
            A python-pptx shape.  Non-picture shapes return ``None``.

    Returns:
        `bytes | None`:
            The raw image bytes, or ``None`` when ``shape`` is not a
            picture / the bytes are unreadable.
    """
    try:
        from pptx.enum.shapes import MSO_SHAPE_TYPE

        picture_type = MSO_SHAPE_TYPE.PICTURE
    except ImportError:
        # MSO_SHAPE_TYPE.PICTURE numeric value used as the fallback
        # so the parser still works against pptx builds where the
        # enum import path has moved.
        picture_type = 13

    if shape.shape_type != picture_type:
        return None
    try:
        return shape.image.blob
    except Exception as e:  # pylint: disable=broad-except
        logger.warning("Failed to extract image from PPT shape: %s", e)
        return None


class PPTParser(ParserBase):
    """Parser for PowerPoint ``.pptx`` files.

    Slide order is preserved.  Within a slide, shapes are visited in
    document order and grouped into a minimum number of sections:

    - **Text** shapes and **table** shapes contribute to one running
      text section.  When ``separate_table=True`` a table closes the
      running section and starts a new one of its own.
    - **Picture** shapes emit a standalone :class:`Section` whose
      ``content`` is a :class:`DataBlock` holding the base64-encoded
      image bytes.

    ``slide_prefix`` / ``slide_suffix`` are wrapped around each
    slide's text content (the prefix is prepended to the slide's
    first text section, the suffix is appended to its last text
    section).  Use ``None`` on either to disable wrapping.
    """

    supported_media_types: list[str] = [
        "application/vnd.openxmlformats-officedocument.presentationml"
        ".presentation",
    ]

    @classmethod
    def supported_extensions(cls) -> list[str]:
        """Return ``[".pptx"]`` — the only format ``python-pptx``
        reads."""
        return [".pptx"]

    def __init__(
        self,
        include_image: bool = True,
        separate_table: bool = False,
        table_format: Literal["markdown", "json"] = "markdown",
        slide_prefix: str | None = "<slide index={index}>",
        slide_suffix: str | None = "</slide>",
    ) -> None:
        """Initialize the PowerPoint parser.

        Args:
            include_image (`bool`, defaults to ``True``):
                When ``True``, picture shapes are emitted as
                :class:`DataBlock` sections.  Set to ``False`` to keep
                a text-only index.
            separate_table (`bool`, defaults to ``False``):
                When ``True``, each table becomes its own text
                section, never merged with surrounding text.
            table_format (`Literal["markdown", "json"]`, defaults to
                ``"markdown"``):
                How to render tables.  ``"markdown"`` uses pipe-table
                syntax; ``"json"`` emits a JSON array prefixed with a
                ``<system-info>`` marker — choose JSON when cells
                contain newlines that would corrupt Markdown layout.
            slide_prefix (`str | None`, defaults to
                ``"<slide index={index}>"``):
                Prepended to the first text section of each slide.
                Supports the ``{index}`` placeholder (starting at 1).  Use
                ``None`` to disable.
            slide_suffix (`str | None`, defaults to ``"</slide>"``):
                Appended to the last text section of each slide.  Use
                ``None`` to disable.

        Raises:
            `ValueError`: If ``table_format`` is not one of
                ``"markdown"`` / ``"json"``.
        """
        if table_format not in ("markdown", "json"):
            raise ValueError(
                "The table_format must be one of 'markdown' or 'json', "
                f"got {table_format!r}.",
            )
        self.include_image = include_image
        self.separate_table = separate_table
        self.table_format = table_format
        self.slide_prefix = slide_prefix
        self.slide_suffix = slide_suffix

    async def parse(
        self,
        file: bytes | str,
        filename: str,
    ) -> list[Section]:
        """Parse a PPTX file into a list of :class:`Section` objects.

        Args:
            file (`bytes | str`):
                Either the raw PPTX bytes, or a filesystem path to
                the PPTX file.
            filename (`str`):
                The source filename, copied into each Section's
                :attr:`Section.source`.

        Returns:
            `list[Section]`:
                Sections in deck order.  Text sections carry
                ``{"slide": <starting at 1>}``; image sections add
                ``{"media_type": "image/..."}``.

        Raises:
            `FileNotFoundError`: If ``file`` is a ``str`` pointing to
                a path that does not exist.
            `ImportError`: If :mod:`python-pptx` is not installed.
            `ValueError`: If the bytes cannot be parsed.
        """
        if isinstance(file, str):
            with open(file, "rb") as fp:
                file = fp.read()

        try:
            from pptx import Presentation
        except ImportError as e:
            raise ImportError(
                "Please install python-pptx to use the PowerPoint "
                "parser. You can install it by "
                "`pip install python-pptx` (or "
                "`pip install agentscope[rag]`).",
            ) from e

        try:
            prs = Presentation(io.BytesIO(file))
        except Exception as e:  # pylint: disable=broad-except
            raise ValueError(
                f"Failed to parse {filename!r} as PPTX: {e}",
            ) from e

        sections: list[Section] = []
        for slide_idx, slide in enumerate(prs.slides):
            sections.extend(
                self._parse_slide(slide, slide_idx, filename),
            )
        return sections

    # ------------------------------------------------------------------
    # Slide-level parsing
    # ------------------------------------------------------------------

    def _parse_slide(
        self,
        slide: Any,
        slide_idx: int,
        filename: str,
    ) -> list[Section]:
        """Walk one slide and return its ordered sections."""
        slide_no = slide_idx + 1
        prefix = (
            self.slide_prefix.format(index=slide_no)
            if self.slide_prefix is not None
            else ""
        )

        slide_sections: list[Section] = []
        # ``text_buffer`` accumulates the running text section; it is
        # flushed whenever an image arrives, the slide ends, or
        # ``separate_table`` forces a break around a table shape.
        text_buffer: list[str] = []

        def flush_text() -> None:
            if not text_buffer:
                return
            slide_sections.append(
                Section(
                    content=TextBlock(text="\n".join(text_buffer)),
                    source=filename,
                    metadata={"slide": slide_no},
                ),
            )
            text_buffer.clear()

        # Slide prefix lives at the very top of the first text section.
        if prefix:
            text_buffer.append(prefix)

        for shape in slide.shapes:
            # 1. Pictures — flush running text, emit a DataBlock section.
            if self.include_image:
                image_bytes = _extract_image_bytes(shape)
                if image_bytes is not None:
                    flush_text()
                    media_type = _guess_image_media_type(image_bytes)
                    data = base64.b64encode(image_bytes).decode("utf-8")
                    slide_sections.append(
                        Section(
                            content=DataBlock(
                                source=Base64Source(
                                    media_type=media_type,
                                    data=data,
                                ),
                                name=filename,
                            ),
                            source=filename,
                            metadata={
                                "slide": slide_no,
                                "media_type": media_type,
                            },
                        ),
                    )
                    continue

            # 2. Tables — render to text; optionally flush around them.
            if getattr(shape, "has_table", False):
                try:
                    rows = _extract_table_rows(shape.table)
                except Exception as e:  # pylint: disable=broad-except
                    logger.warning(
                        "Failed to extract table from slide %d: %s",
                        slide_no,
                        e,
                    )
                    continue
                rendered = (
                    _table_to_markdown(rows)
                    if self.table_format == "markdown"
                    else _table_to_json(rows)
                )
                if not rendered:
                    continue
                if self.separate_table:
                    flush_text()
                    slide_sections.append(
                        Section(
                            content=TextBlock(text=rendered),
                            source=filename,
                            metadata={"slide": slide_no},
                        ),
                    )
                else:
                    text_buffer.append(rendered)
                continue

            # 3. Text frames.
            if getattr(shape, "has_text_frame", False):
                try:
                    parts = [
                        para.text.strip()
                        for para in shape.text_frame.paragraphs
                        if para.text.strip()
                    ]
                except Exception as e:  # pylint: disable=broad-except
                    logger.warning(
                        "Failed to extract text from shape in slide %d: %s",
                        slide_no,
                        e,
                    )
                    continue
                if parts:
                    text_buffer.append("\n".join(parts))

        # Suffix goes onto the last text section of this slide.  If the
        # slide ends on an image (text_buffer empty, no prior text
        # section in this slide), create a small text-only carrier so
        # the suffix is preserved.
        if self.slide_suffix is not None:
            text_buffer.append(self.slide_suffix)

        flush_text()
        return slide_sections
