import json
import tempfile
import unittest
from contextlib import contextmanager
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from uuid import uuid4

from core.artifact_lifecycle import artifact_subtree_clause, trash_active_artifacts
from core.artifacts import (
    ArtifactPathError,
    ArtifactRef,
    ToolExecutionResult,
    resolve_artifact_path,
)
from core.executor import ExecCtx
from core.executor_host import HostExecutor
from tools.publish_artifacts import PublishArtifactsTool
from tools.register_artifact import RegisterArtifactTool
from web.routers.files import _task_file_target


class ArtifactPathTests(unittest.TestCase):
    def setUp(self) -> None:
        self.tmp = tempfile.TemporaryDirectory()
        self.root = Path(self.tmp.name)
        self.wd = self.root / "技术讨论"
        self.wd.mkdir()
        (self.wd / "report.pdf").write_bytes(b"pdf")

    def tearDown(self) -> None:
        self.tmp.cleanup()

    def test_canonical_and_legacy_paths_resolve_to_same_file(self) -> None:
        expected = self.wd / "report.pdf"
        for raw in (
            "report.pdf",
            "技术讨论/report.pdf",
            str(expected),
            "/workspace/技术讨论/report.pdf",
        ):
            with self.subTest(raw=raw):
                actual, rel = resolve_artifact_path(
                    raw, working_dir=self.wd, user_root=self.root,
                )
                self.assertEqual(actual, expected.resolve())
                self.assertEqual(rel, "report.pdf")

    def test_version_two_ref_carries_stable_artifact_identity(self) -> None:
        artifact_id = uuid4()
        ref = ArtifactRef(
            path="report.pdf",
            label="最终报告",
            artifact_id=artifact_id,
            version=2,
        ).as_dict()
        self.assertEqual(ref["version"], 2)
        self.assertEqual(ref["artifact_id"], str(artifact_id))

    def test_trash_moves_file_and_marks_lifecycle_row_deleted(self) -> None:
        artifact = self.wd / "report.pdf"
        row = SimpleNamespace(
            current_path="技术讨论/report.pdf",
            status="active",
            deleted_at=None,
            trash_path=None,
        )
        session = MagicMock()
        session.execute.return_value.scalars.return_value.all.return_value = [row]

        @contextmanager
        def fake_scope():
            yield session

        with patch("core.artifact_lifecycle.session_scope", fake_scope):
            count = trash_active_artifacts(
                user_id=uuid4(),
                user_root=self.root,
                target=artifact,
            )

        self.assertEqual(count, 1)
        self.assertFalse(artifact.exists())
        self.assertEqual(row.status, "deleted")
        self.assertIsNotNone(row.deleted_at)
        trashed = self.root / row.trash_path
        self.assertTrue(trashed.is_file())
        self.assertEqual(trashed.read_bytes(), b"pdf")

    def test_subtree_query_escapes_like_wildcards(self) -> None:
        clause = artifact_subtree_clause("项目_100%/报告")
        compiled = clause.compile().params
        self.assertIn("项目\\_100\\%/报告/%", compiled.values())

    def test_explicit_dot_slash_disambiguates_same_named_subdirectory(self) -> None:
        nested = self.wd / "技术讨论" / "nested.html"
        nested.parent.mkdir()
        nested.write_text("ok", encoding="utf-8")
        actual, rel = resolve_artifact_path(
            "./技术讨论/nested.html", working_dir=self.wd, user_root=self.root,
        )
        self.assertEqual(actual, nested.resolve())
        self.assertEqual(rel, "技术讨论/nested.html")

    def test_escape_and_directory_are_rejected(self) -> None:
        for raw in ("../outside.txt", "."):
            with self.subTest(raw=raw), self.assertRaises(ArtifactPathError):
                resolve_artifact_path(
                    raw, working_dir=self.wd, user_root=self.root,
                )

    def test_publish_artifacts_is_explicit_bounded_and_deduplicated(self) -> None:
        tool = PublishArtifactsTool(
            working_dir=self.wd,
            base_dir=self.wd,
            user_root=self.root,
        )
        result = tool.execute({"not": "a list"})
        self.assertIsInstance(result, str)
        published = tool.execute([
            {"path": "report.pdf", "label": "最终报告"},
            {"path": "./report.pdf"},
        ])
        self.assertIsInstance(published, ToolExecutionResult)
        self.assertEqual(len(published.artifacts), 1)
        self.assertEqual(published.artifacts[0].path, "report.pdf")
        self.assertEqual(published.artifacts[0].label, "最终报告")

        executed = HostExecutor({tool.name: tool}).call_tool(
            tool.name,
            {"artifacts": [{"path": "report.pdf"}]},
            ExecCtx(user_id="u", task_id="t", working_dir=self.wd),
        )
        self.assertEqual(executed.content, "[OK] published 1 artifact(s): report.pdf")
        self.assertEqual(executed.artifacts[0]["path"], "report.pdf")

    def test_register_artifact_returns_identity_without_publishing(self) -> None:
        artifact_id = uuid4()
        tool = RegisterArtifactTool(
            uuid4(),
            uuid4(),
            working_dir=self.wd,
            base_dir=self.wd,
            user_root=self.root,
        )
        with patch(
            "tools.register_artifact.register_workspace_artifact",
            return_value={
                "version": 2,
                "artifact_id": str(artifact_id),
                "scope": "working_dir",
                "path": "report.pdf",
            },
        ) as register:
            result = json.loads(tool.execute("report.pdf"))

        self.assertEqual(result["artifact_id"], str(artifact_id))
        self.assertEqual(register.call_args.kwargs["path"], "report.pdf")

    def test_register_artifact_rejects_path_escape(self) -> None:
        tool = RegisterArtifactTool(
            uuid4(),
            uuid4(),
            working_dir=self.wd,
            base_dir=self.wd,
            user_root=self.root,
        )
        self.assertIn("cannot register artifact", tool.execute("../outside.csv"))

    def test_register_artifact_schema_is_distinct_from_publish(self) -> None:
        self.assertEqual(RegisterArtifactTool.parameters["required"], ["path"])
        self.assertNotIn("artifacts", RegisterArtifactTool.parameters["properties"])

    def test_publish_path_is_strictly_task_relative_when_names_repeat(self) -> None:
        nested = self.wd / "技术讨论" / "nested.html"
        nested.parent.mkdir()
        nested.write_text("ok", encoding="utf-8")
        tool = PublishArtifactsTool(
            working_dir=self.wd,
            base_dir=self.wd,
            user_root=self.root,
        )
        published = tool.execute([{"path": "技术讨论/nested.html"}])
        self.assertIsInstance(published, ToolExecutionResult)
        self.assertEqual(published.artifacts[0].path, "技术讨论/nested.html")

    def test_legacy_card_resolution_covers_both_known_shapes_and_rename(self) -> None:
        direct = self.wd / "manual.pdf"
        direct.write_bytes(b"direct")
        nested = self.wd / "技术讨论" / "nested.html"
        nested.parent.mkdir()
        nested.write_text("nested", encoding="utf-8")

        # 92ac20cf shape: old user-root path already points at the correct file.
        self.assertEqual(
            _task_file_target(self.root, self.wd, "技术讨论/manual.pdf", True),
            direct.resolve(),
        )
        # 9b4502aa shape: the same text was actually task-relative into a repeated dir.
        self.assertEqual(
            _task_file_target(self.root, self.wd, "技术讨论/nested.html", True),
            nested.resolve(),
        )
        # After a top-level rename, dropping the obsolete first component finds the file.
        self.assertEqual(
            _task_file_target(self.root, self.wd, "旧目录/manual.pdf", True),
            direct.resolve(),
        )
