"""Word 与 Excel 原文件的安全、受限预览模型。

预览只返回浏览器绘制所需的结构化数据，不返回或执行 Office 包中的活动内容。
DOCX 的字符偏移与上传时的正文抽取规则保持一致，供前端定位当前切片。
"""

from __future__ import annotations

import io
import re
from typing import Any

from docx import Document
from docx.oxml.table import CT_Tbl
from docx.oxml.text.paragraph import CT_P
from docx.table import Table
from docx.text.paragraph import Paragraph
from openpyxl import load_workbook

from app.modules.data_process.algorithms import (
    _MAX_WORKBOOK_COLUMNS,
    _MAX_WORKBOOK_HEADER_SCAN_ROWS,
    _infer_xlsx_header_region,
    _normalize_spreadsheet_value,
    _rewrite_xlsx_workbook_relationships,
    _validate_office_archive,
    _xlsx_sheet_merge_ranges,
    normalize_text,
)

MAX_DOCX_PREVIEW_BLOCKS = 2_000
MAX_XLSX_PREVIEW_ROWS = 200


def _docx_alignment(paragraph: Paragraph) -> str:
    value = paragraph.alignment
    return {
        0: "left",
        1: "center",
        2: "right",
        3: "justify",
        4: "distribute",
        5: "justify",
        7: "justify",
        8: "distribute",
        9: "distribute",
    }.get(int(value) if value is not None else -1, "left")


def _docx_heading_level(paragraph: Paragraph) -> int | None:
    style = paragraph.style
    if style is None:
        return None
    style_name = str(style.name or "")
    style_id = str(style.style_id or "")
    match = re.search(r"(?:heading|标题)\s*([1-6])", f"{style_name} {style_id}", re.IGNORECASE)
    return int(match.group(1)) if match else None


def build_docx_preview(raw: bytes) -> dict[str, Any]:
    """把 DOCX 转为保留标题、段落和表格顺序的浏览器预览模型。"""

    _validate_office_archive(raw, "docx")
    try:
        document = Document(io.BytesIO(raw))
    except Exception as exc:
        raise ValueError(f"invalid DOCX file: {exc}") from exc

    blocks: list[dict[str, Any]] = []
    source_cursor = 0
    has_source_content = False
    rendered_blocks = 0
    truncated = False

    def source_range(value: str) -> tuple[str, int, int] | None:
        nonlocal source_cursor, has_source_content
        text = normalize_text(value)
        if not text:
            return None
        if has_source_content:
            source_cursor += 2
        start = source_cursor
        source_cursor += len(text)
        has_source_content = True
        return text, start, source_cursor

    for child in document.element.body.iterchildren():
        if rendered_blocks >= MAX_DOCX_PREVIEW_BLOCKS:
            truncated = True
            break

        if isinstance(child, CT_P):
            paragraph = Paragraph(child, document)
            located = source_range(paragraph.text)
            if located is None:
                continue
            text, start, end = located
            style_name = str(paragraph.style.name or "") if paragraph.style else ""
            blocks.append(
                {
                    "type": "paragraph",
                    "text": text,
                    "style": style_name,
                    "heading_level": _docx_heading_level(paragraph),
                    "alignment": _docx_alignment(paragraph),
                    "is_list": "list" in style_name.casefold() or "列表" in style_name,
                    "source_start": start,
                    "source_end": end,
                }
            )
            rendered_blocks += 1
            continue

        if not isinstance(child, CT_Tbl):
            continue
        table = Table(child, document)
        preview_rows: list[dict[str, Any]] = []
        for row in table.rows:
            if rendered_blocks >= MAX_DOCX_PREVIEW_BLOCKS:
                truncated = True
                break
            cell_values = [normalize_text(cell.text) for cell in row.cells]
            located = source_range("\t".join(cell_values))
            if located is None:
                continue
            _, start, end = located
            preview_rows.append(
                {
                    "cells": cell_values,
                    "source_start": start,
                    "source_end": end,
                }
            )
            rendered_blocks += 1
        if preview_rows:
            blocks.append({"type": "table", "rows": preview_rows})
        if truncated:
            break

    return {
        "format": "docx",
        "blocks": blocks,
        "truncated": truncated,
    }


def build_xlsx_preview(
    raw: bytes,
    *,
    sheet_index: int = 0,
    offset: int = 0,
    limit: int = 100,
) -> dict[str, Any]:
    """按工作表分页返回 XLSX 的表头和记录网格。"""

    if sheet_index < 0 or offset < 0:
        raise ValueError("sheet_index and offset must be non-negative")
    if limit < 1 or limit > MAX_XLSX_PREVIEW_ROWS:
        raise ValueError(
            f"XLSX preview limit must be in [1, {MAX_XLSX_PREVIEW_ROWS}]"
        )

    _validate_office_archive(raw, "xlsx")
    merged_by_sheet, normalized_targets = _xlsx_sheet_merge_ranges(raw)
    workbook_raw = (
        _rewrite_xlsx_workbook_relationships(raw, normalized_targets)
        if normalized_targets
        else raw
    )
    try:
        workbook = load_workbook(
            io.BytesIO(workbook_raw),
            read_only=True,
            data_only=True,
            keep_links=False,
        )
    except Exception as exc:
        raise ValueError(f"invalid XLSX file: {exc}") from exc

    try:
        sheets = [
            {
                "index": index,
                "name": worksheet.title,
                "state": worksheet.sheet_state,
            }
            for index, worksheet in enumerate(workbook.worksheets)
        ]
        if not sheets:
            raise ValueError("XLSX workbook contains no worksheets")
        if sheet_index >= len(sheets):
            raise ValueError("XLSX worksheet index is out of range")

        worksheet = workbook.worksheets[sheet_index]
        reset_dimensions = getattr(worksheet, "reset_dimensions", None)
        if callable(reset_dimensions):
            reset_dimensions()
        row_iterator = enumerate(worksheet.iter_rows(values_only=True), start=1)
        buffered_rows: dict[int, tuple[Any, ...]] = {}

        def normalized_values(row: tuple[Any, ...]) -> list[Any]:
            values = list(row)
            while values and values[-1] in {None, ""}:
                values.pop()
            if len(values) > _MAX_WORKBOOK_COLUMNS:
                raise ValueError(
                    f"XLSX worksheet {worksheet.title!r} exceeds "
                    f"{_MAX_WORKBOOK_COLUMNS} columns"
                )
            return values

        for row_number, row in row_iterator:
            values = normalized_values(row)
            if not values or all(value in {None, ""} for value in values):
                continue
            buffered_rows[row_number] = tuple(values)
            if len(buffered_rows) >= _MAX_WORKBOOK_HEADER_SCAN_ROWS:
                break

        if not buffered_rows:
            return {
                "format": "xlsx",
                "sheets": sheets,
                "active_sheet": {
                    "index": sheet_index,
                    "name": worksheet.title,
                    "columns": [],
                    "rows": [],
                    "offset": offset,
                    "limit": limit,
                    "has_more": False,
                },
            }

        _, header_end_row, headers = _infer_xlsx_header_region(
            worksheet.title,
            buffered_rows,
            merged_by_sheet.get(worksheet.title, ()),
        )
        preview_rows: list[dict[str, Any]] = []
        record_index = 0
        has_more = False

        def append_row(row_number: int, values: tuple[Any, ...] | list[Any]) -> bool:
            nonlocal record_index, has_more
            row_values = list(values)
            if len(row_values) > len(headers):
                raise ValueError(
                    f"XLSX worksheet {worksheet.title!r} has a row wider than its header"
                )
            row_values.extend([None] * (len(headers) - len(row_values)))
            record = {
                header: _normalize_spreadsheet_value(value)
                for header, value in zip(headers, row_values, strict=True)
            }
            if not any(value not in {"", None} for value in record.values()):
                return False
            current_index = record_index
            record_index += 1
            if current_index < offset:
                return False
            if len(preview_rows) >= limit:
                has_more = True
                return True
            preview_rows.append(
                {
                    "row_number": row_number,
                    "record_index": current_index,
                    "values": [record[header] for header in headers],
                    "record": record,
                }
            )
            return False

        for row_number, values in buffered_rows.items():
            if row_number > header_end_row and append_row(row_number, values):
                break
        else:
            for row_number, row in row_iterator:
                values = normalized_values(row)
                if not values or all(value in {None, ""} for value in values):
                    continue
                if append_row(row_number, values):
                    break

        return {
            "format": "xlsx",
            "sheets": sheets,
            "active_sheet": {
                "index": sheet_index,
                "name": worksheet.title,
                "columns": headers,
                "rows": preview_rows,
                "offset": offset,
                "limit": limit,
                "has_more": has_more,
            },
        }
    finally:
        workbook.close()


__all__ = [
    "MAX_DOCX_PREVIEW_BLOCKS",
    "MAX_XLSX_PREVIEW_ROWS",
    "build_docx_preview",
    "build_xlsx_preview",
]
