"""web/pptx_render.py 的 focused tests(DESIGN §8.3 验收)。

soffice 本机不一定装,全部 mock 掉 `_run_soffice` / `find_soffice` —— 只验缓存 / 失效 /
降级 / prune 这些纯逻辑;真 soffice 转换由部署环境兜底,不在单测里跑。
"""
import os
import time
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock

import web.pptx_render as R
from web.app import _safe_join  # 路径越界拒绝


def _fake_soffice_run(soffice, pptx_path, outdir, timeout):
    """假装 soffice:在 outdir 落一个 <stem>.pdf,返回其路径。"""
    out = Path(outdir) / f"{Path(pptx_path).stem}.pdf"
    out.write_bytes(b"%PDF-1.4 fake\n")
    return out


class PptxRenderTests(unittest.TestCase):
    def _make_pptx(self, d: Path, name: str = "deck.pptx") -> Path:
        p = d / name
        p.write_bytes(b"PK\x03\x04 fake pptx bytes")
        return p

    def test_convert_success_lands_pdf_in_preview_dir(self):
        with TemporaryDirectory() as tmp:
            pptx = self._make_pptx(Path(tmp))
            with mock.patch.object(R, "find_soffice", return_value="soffice"), \
                 mock.patch.object(R, "_run_soffice", side_effect=_fake_soffice_run):
                pdf = R.pptx_to_pdf(pptx)
            self.assertTrue(pdf.exists())
            self.assertEqual(pdf.parent.name, ".preview")
            self.assertEqual(pdf.suffix, ".pdf")
            self.assertTrue(pdf.name.startswith("deck."))

    def test_cache_hit_skips_soffice(self):
        with TemporaryDirectory() as tmp:
            pptx = self._make_pptx(Path(tmp))
            with mock.patch.object(R, "find_soffice", return_value="soffice"), \
                 mock.patch.object(R, "_run_soffice", side_effect=_fake_soffice_run):
                first = R.pptx_to_pdf(pptx)
            # 第二次:soffice 一律不该被调用(命中缓存);find_soffice 也不必走
            with mock.patch.object(R, "_run_soffice") as run, \
                 mock.patch.object(R, "find_soffice") as find:
                second = R.pptx_to_pdf(pptx)
            self.assertEqual(first, second)
            run.assert_not_called()
            find.assert_not_called()

    def test_source_change_invalidates_and_prunes_old(self):
        with TemporaryDirectory() as tmp:
            pptx = self._make_pptx(Path(tmp))
            with mock.patch.object(R, "find_soffice", return_value="soffice"), \
                 mock.patch.object(R, "_run_soffice", side_effect=_fake_soffice_run):
                old_pdf = R.pptx_to_pdf(pptx)
                # 改源内容 + 推后 mtime → hash 变 → 重转 + 删旧 hash
                time.sleep(0.01)
                pptx.write_bytes(b"PK\x03\x04 fake pptx bytes CHANGED MORE")
                new_mtime = time.time() + 5
                os.utime(pptx, (new_mtime, new_mtime))
                new_pdf = R.pptx_to_pdf(pptx)
            self.assertNotEqual(old_pdf.name, new_pdf.name)
            self.assertTrue(new_pdf.exists())
            self.assertFalse(old_pdf.exists(), "旧 hash 缓存应被 prune")
            # .preview 下只剩这一份
            survivors = list(new_pdf.parent.glob("deck.*.pdf"))
            self.assertEqual(survivors, [new_pdf])

    def test_missing_soffice_raises_not_found(self):
        with TemporaryDirectory() as tmp:
            pptx = self._make_pptx(Path(tmp))
            with mock.patch.object(R, "find_soffice",
                                   side_effect=R.SofficeNotFoundError("no soffice")):
                with self.assertRaises(R.SofficeNotFoundError):
                    R.pptx_to_pdf(pptx)

    def test_missing_pptx_raises_convert_error(self):
        with TemporaryDirectory() as tmp:
            with self.assertRaises(R.PptxConvertError):
                R.pptx_to_pdf(Path(tmp) / "nope.pptx")

    def test_find_soffice_uses_path_fallback(self):
        with mock.patch("pathlib.Path.exists", return_value=False), \
             mock.patch("shutil.which", return_value="/usr/bin/soffice") as which:
            self.assertEqual(R.find_soffice(), "/usr/bin/soffice")
            self.assertTrue(which.called)

    def test_find_soffice_missing_raises(self):
        with mock.patch("pathlib.Path.exists", return_value=False), \
             mock.patch("shutil.which", return_value=None):
            with self.assertRaises(R.SofficeNotFoundError):
                R.find_soffice()


class SafePathTraversalTests(unittest.TestCase):
    """preview_pdf 端点复用 _safe_join 做边界:越界路径必须被挡。"""

    def test_rejects_parent_traversal(self):
        with TemporaryDirectory() as tmp:
            root = Path(tmp)
            with self.assertRaises(Exception):
                _safe_join(root, "../../etc/passwd")

    def test_rejects_absolute(self):
        with TemporaryDirectory() as tmp:
            root = Path(tmp)
            with self.assertRaises(Exception):
                _safe_join(root, "/etc/passwd")

    def test_allows_inside(self):
        with TemporaryDirectory() as tmp:
            root = Path(tmp)
            got = _safe_join(root, "sub/deck.pptx")
            self.assertEqual(got, (root / "sub" / "deck.pptx").resolve())


if __name__ == "__main__":
    unittest.main()
