"""Office 文档转 PDF(host-side LibreOffice)。

Docker 沙盒内不安装 LibreOffice；本工具运行在 backend host，把用户工作区内已有的
DOCX/PPTX/XLSX/ODF 文件交给 host `soffice --headless` 转成 PDF。新写的 Markdown
报告不走这里，统一由沙盒平台渲染器 `rendering/render.py --format pdf` 直出。
"""
from __future__ import annotations

import os
import shutil
import subprocess
import tempfile
from functools import lru_cache
from pathlib import Path
from typing import Optional
from uuid import uuid4

from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
from tools.base import FileOutOfBounds, Tool
from web.pptx_render import SofficeNotFoundError, find_soffice


_FAMILY_SUFFIXES = {
    "Writer": frozenset({".doc", ".docx", ".odt"}),
    "Calc": frozenset({".xls", ".xlsx", ".ods"}),
    "Impress": frozenset({".ppt", ".pptx", ".odp"}),
}
_KNOWN_SUFFIXES = frozenset().union(*_FAMILY_SUFFIXES.values())
_DEBIAN_PACKAGES = {
    "Writer": ("libreoffice-writer", "libreoffice-writer-nogui"),
    "Calc": ("libreoffice-calc", "libreoffice-calc-nogui"),
    "Impress": ("libreoffice-impress", "libreoffice-impress-nogui"),
}
_DEFAULT_TIMEOUT = 120


def _dpkg_package_installed(package: str) -> bool:
    try:
        proc = subprocess.run(
            ["dpkg-query", "-W", "-f=${Status}", package],
            capture_output=True,
            timeout=5,
            check=False,
            text=True,
        )
    except (OSError, subprocess.TimeoutExpired):
        return False
    return proc.returncode == 0 and "install ok installed" in proc.stdout


def _debian_office_families(soffice: str) -> Optional[frozenset[str]]:
    """标准 Debian/Ubuntu 包安装时返回已安装组件；其他安装形态返回 None。"""
    if os.name != "posix" or shutil.which("dpkg-query") is None:
        return None
    try:
        resolved = Path(soffice).resolve()
    except OSError:
        return None
    if not resolved.is_relative_to("/usr"):
        return None
    return frozenset(
        family
        for family, packages in _DEBIAN_PACKAGES.items()
        if any(_dpkg_package_installed(package) for package in packages)
    )


@lru_cache(maxsize=1)
def office_supported_suffixes() -> frozenset[str]:
    """返回 host 当前 LibreOffice 组件实际支持的输入后缀。"""
    try:
        soffice = find_soffice()
    except SofficeNotFoundError:
        return frozenset()
    families = _debian_office_families(soffice)
    if families is None:
        # Windows/macOS/自带 tar 包通常是完整套件；无法可靠拆包探测时按完整安装处理。
        return _KNOWN_SUFFIXES
    return frozenset().union(*(_FAMILY_SUFFIXES[family] for family in families))


def soffice_available() -> bool:
    """Host 至少有一个可用 LibreOffice 组件时才向 agent 注册工具。"""
    return bool(office_supported_suffixes())


class OfficeToPdfTool(Tool):
    name = "office_to_pdf"
    description = "Convert an existing Office document to PDF using backend host LibreOffice."
    parameters = {
        "type": "object",
        "properties": {
            "source": {
                "type": "string",
                "description": (
                    "Existing Office file path: task-relative, user-root-relative as shown in chat, "
                    "or an absolute path under /workspace."
                ),
            },
            "output": {
                "type": "string",
                "description": (
                    "Optional output .pdf path in the user's workspace. "
                    "Defaults to the source path with a .pdf suffix."
                ),
            },
        },
        "required": ["source"],
    }

    def __init__(
        self,
        base_dir: Optional[Path] = None,
        user_root: Optional[Path] = None,
    ) -> None:
        super().__init__(base_dir=base_dir, user_root=user_root)
        self.supported_suffixes = office_supported_suffixes()
        supported = ", ".join(suffix.lstrip(".").upper() for suffix in sorted(self.supported_suffixes))
        self.description = (
            "Convert an existing Office document in the current user's workspace to PDF using "
            f"LibreOffice on the backend host. Formats available on this host: {supported or 'none'}. "
            "Use this only when an Office file already exists; for a new Markdown report, call "
            "the platform rendering/render.py command with --format pdf directly."
        )

    def _resolve_office_path(self, raw: str, *, existing: bool) -> Path:
        """兼容 task 相对路径与界面展示的 `<working_dir>/...` 用户根相对路径。"""
        p = Path(raw)
        if p.is_absolute() or raw == "/workspace" or raw.startswith("/workspace/"):
            return self._resolve_user_file(raw)

        base_candidate = self._resolve_user_file(raw)
        if existing and base_candidate.exists():
            return base_candidate
        if self.user_root is None:
            return base_candidate

        root = self.user_root.resolve()
        root_candidate = (root / p).resolve()
        try:
            root_candidate.relative_to(root)
            task_rel = self.base_dir.resolve().relative_to(root)
        except ValueError:
            return base_candidate

        has_task_prefix = p.parts[:len(task_rel.parts)] == task_rel.parts
        if (existing and root_candidate.exists()) or has_task_prefix:
            return root_candidate
        return base_candidate

    def execute(self, source: str, output: Optional[str] = None) -> str | ToolExecutionResult:
        try:
            src = self._resolve_office_path(source, existing=True)
        except FileOutOfBounds:
            return f"[Error] source path out of user workspace: {source}"
        if not src.is_file():
            return f"[Error] source file not found: {source}"
        suffix = src.suffix.lower()
        if suffix not in _KNOWN_SUFFIXES:
            known = ", ".join(sorted(_KNOWN_SUFFIXES))
            return f"[Error] unsupported Office format {src.suffix!r}; known formats: {known}"
        if suffix not in self.supported_suffixes:
            family = next(name for name, suffixes in _FAMILY_SUFFIXES.items() if suffix in suffixes)
            return (
                f"[Error] backend host LibreOffice lacks the {family} component required for "
                f"{suffix}; ask the administrator to install that component"
            )

        raw_out = output.strip() if isinstance(output, str) and output.strip() else ""
        try:
            out = self._resolve_office_path(raw_out, existing=False) if raw_out else src.with_suffix(".pdf")
        except FileOutOfBounds:
            return f"[Error] output path out of user workspace: {output}"
        if out.suffix.lower() != ".pdf":
            return f"[Error] output must end with .pdf: {output}"

        try:
            soffice = find_soffice()
        except SofficeNotFoundError as e:
            return f"[Error] LibreOffice unavailable on backend host: {e}"

        out.parent.mkdir(parents=True, exist_ok=True)
        try:
            with tempfile.TemporaryDirectory(prefix="office-pdf-") as tmp:
                tmp_dir = Path(tmp)
                profile_uri = (tmp_dir / "profile").resolve().as_uri()
                cmd = [
                    soffice,
                    "--headless",
                    "--norestore",
                    "--nolockcheck",
                    "--nodefault",
                    f"-env:UserInstallation={profile_uri}",
                    "--convert-to",
                    "pdf",
                    "--outdir",
                    str(tmp_dir),
                    str(src),
                ]
                proc = subprocess.run(
                    cmd,
                    capture_output=True,
                    timeout=_DEFAULT_TIMEOUT,
                    check=False,
                )
                converted = tmp_dir / f"{src.stem}.pdf"
                if proc.returncode != 0 or not converted.is_file() or converted.stat().st_size == 0:
                    detail = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace")[-500:]
                    return f"[Error] LibreOffice conversion failed (rc={proc.returncode}): {detail}"

                staged = out.with_name(f".{out.name}.{uuid4().hex}.tmp")
                try:
                    shutil.copyfile(converted, staged)
                    staged.replace(out)
                finally:
                    staged.unlink(missing_ok=True)
        except subprocess.TimeoutExpired:
            return f"[Error] LibreOffice conversion timed out after {_DEFAULT_TIMEOUT}s: {source}"
        except OSError as e:
            return f"[Error] failed to publish PDF: {type(e).__name__}: {e}"

        content = f"[OK] PDF created: {self._display(out)} ({out.stat().st_size} bytes)"
        if self.user_root is None:
            return content
        try:
            _, rel = resolve_artifact_path(
                str(out), working_dir=self.base_dir, user_root=self.user_root,
            )
        except ValueError:
            return content
        return ToolExecutionResult(content=content, artifacts=(ArtifactRef(path=rel),))
