from __future__ import annotations

import io
import uuid
from pathlib import Path
from typing import cast

import pytest

from agents.sandbox import Manifest, SandboxPathGrant
from agents.sandbox.capabilities import LocalDirLazySkillSource, Skill, Skills
from agents.sandbox.entries import Dir, File, LocalDir
from agents.sandbox.errors import SkillsConfigError
from agents.sandbox.files import EntryKind, FileEntry
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
from agents.sandbox.snapshot import NoopSnapshot
from agents.sandbox.types import ExecResult, Permissions, User
from agents.sandbox.workspace_paths import coerce_posix_path
from agents.tool import FunctionTool
from agents.tool_context import ToolContext
from tests.utils.factories import TestSessionState


def _children_keys(entry: Dir) -> set[str]:
    return {coerce_posix_path(key).as_posix() for key in entry.children}


def _source_granted_manifest(root: str | Path = "/workspace", *, source: Path) -> Manifest:
    return Manifest(root=str(root), extra_path_grants=(SandboxPathGrant(path=str(source)),))


def _user_name(user: object) -> str | None:
    if user is None:
        return None
    if isinstance(user, User):
        return user.name
    if isinstance(user, str):
        return user
    return str(user)


class _SkillsSession(BaseSandboxSession):
    def __init__(self, manifest: Manifest) -> None:
        self.state = TestSessionState(
            manifest=manifest,
            snapshot=NoopSnapshot(id=str(uuid.uuid4())),
        )
        self.read_users: list[str | None] = []
        self.write_users: list[str | None] = []
        self.mkdir_users: list[str | None] = []

    async def start(self) -> None:
        return None

    async def stop(self) -> None:
        return None

    async def shutdown(self) -> None:
        return None

    async def running(self) -> bool:
        return True

    async def read(self, path: Path, *, user: object = None) -> io.BytesIO:
        self.read_users.append(_user_name(user))
        normalized = self.normalize_path(path)
        return io.BytesIO(normalized.read_bytes())

    async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None:
        self.write_users.append(_user_name(user))
        normalized = self.normalize_path(path)
        normalized.parent.mkdir(parents=True, exist_ok=True)
        payload = data.read()
        if isinstance(payload, str):
            normalized.write_text(payload, encoding="utf-8")
        else:
            normalized.write_bytes(bytes(payload))

    async def _exec_internal(
        self,
        *command: str | Path,
        timeout: float | None = None,
    ) -> ExecResult:
        _ = (command, timeout)
        return ExecResult(stdout=b"", stderr=b"", exit_code=0)

    async def persist_workspace(self) -> io.IOBase:
        return io.BytesIO()

    async def hydrate_workspace(self, data: io.IOBase) -> None:
        _ = data

    async def mkdir(
        self,
        path: Path | str,
        *,
        parents: bool = False,
        user: object = None,
    ) -> None:
        self.mkdir_users.append(_user_name(user))
        normalized = self.normalize_path(path)
        normalized.mkdir(parents=parents, exist_ok=True)

    async def ls(
        self,
        path: Path | str,
        *,
        user: object = None,
    ) -> list[FileEntry]:
        _ = user
        normalized = self.normalize_path(path)
        if not normalized.exists():
            raise FileNotFoundError(normalized)
        entries: list[FileEntry] = []
        for child in sorted(normalized.iterdir(), key=lambda entry: entry.name):
            stat_result = child.stat()
            entries.append(
                FileEntry(
                    path=str(child),
                    permissions=Permissions.from_mode(stat_result.st_mode),
                    owner="owner",
                    group="group",
                    size=stat_result.st_size,
                    kind=EntryKind.DIRECTORY if child.is_dir() else EntryKind.FILE,
                )
            )
        return entries


class TestSkillValidation:
    def test_rejects_directory_content_artifact(self) -> None:
        with pytest.raises(SkillsConfigError):
            Skill(name="my-skill", description="desc", content=Dir())

    def test_rejects_duplicate_script_paths_after_normalization(self) -> None:
        with pytest.raises(SkillsConfigError):
            Skill(
                name="my-skill",
                description="desc",
                content="literal",
                scripts={
                    "run.sh": File(content=b"echo one"),
                    Path("run.sh"): File(content=b"echo two"),
                },
            )


class TestSkillsValidation:
    def test_requires_at_least_one_source(self) -> None:
        with pytest.raises(SkillsConfigError):
            Skills()

    def test_rejects_non_directory_from_artifact(self) -> None:
        with pytest.raises(SkillsConfigError):
            Skills(from_=File(content=b"not-a-dir"))

    def test_rejects_duplicate_skill_names(self) -> None:
        with pytest.raises(SkillsConfigError):
            Skills(
                skills=[
                    Skill(name="dup", description="first", content="a"),
                    Skill(name="dup", description="second", content="b"),
                ]
            )

    def test_rejects_combining_literal_and_from_sources(self) -> None:
        with pytest.raises(SkillsConfigError):
            Skills(
                from_=Dir(
                    children={"my-skill": Dir(children={"SKILL.md": File(content=b"imported")})}
                ),
                skills=[Skill(name="my-skill", description="desc", content="literal")],
            )

    def test_rejects_combining_literal_and_lazy_sources(self) -> None:
        with pytest.raises(SkillsConfigError):
            Skills(
                skills=[Skill(name="my-skill", description="desc", content="literal")],
                lazy_from=LocalDirLazySkillSource(source=LocalDir(src=Path("skills"))),
            )

    def test_rejects_absolute_skills_path(self) -> None:
        with pytest.raises(SkillsConfigError):
            Skills(
                skills=[Skill(name="my-skill", description="desc", content="literal")],
                skills_path="/skills",
            )

    def test_rejects_windows_drive_absolute_skills_path(self) -> None:
        with pytest.raises(SkillsConfigError) as exc_info:
            Skills(
                skills=[Skill(name="my-skill", description="desc", content="literal")],
                skills_path="C:\\skills",
            )

        assert exc_info.value.context == {
            "field": "skills_path",
            "path": "C:/skills",
            "reason": "absolute",
        }

    def test_rejects_escape_root_skills_path(self) -> None:
        with pytest.raises(SkillsConfigError):
            Skills(
                skills=[Skill(name="my-skill", description="desc", content="literal")],
                skills_path="../skills",
            )


class TestSkillsManifest:
    def test_literals_materialize_full_skill_structure(self) -> None:
        capability = Skills(
            skills=[
                Skill(
                    name="my-skill",
                    description="desc",
                    content="Use this skill.",
                    scripts={"run.sh": File(content=b"echo run")},
                    references={"docs/readme.md": File(content=b"ref")},
                    assets={"images/icon.txt": File(content=b"asset")},
                )
            ]
        )

        processed = capability.process_manifest(Manifest(root="/workspace"))
        skill_entry = processed.entries[Path(".agents/my-skill")]
        assert isinstance(skill_entry, Dir)
        assert _children_keys(skill_entry) == {"SKILL.md", "assets", "references", "scripts"}

        scripts = skill_entry.children["scripts"]
        assert isinstance(scripts, Dir)
        assert _children_keys(scripts) == {"run.sh"}

        references = skill_entry.children["references"]
        assert isinstance(references, Dir)
        assert _children_keys(references) == {"docs/readme.md"}

        assets = skill_entry.children["assets"]
        assert isinstance(assets, Dir)
        assert _children_keys(assets) == {"images/icon.txt"}

    def test_from_source_is_mapped_to_skills_root(self) -> None:
        source = Dir(children={"imported": Dir(children={"SKILL.md": File(content=b"imported")})})
        capability = Skills(from_=source)

        processed = capability.process_manifest(Manifest(root="/workspace"))
        assert processed.entries[Path(".agents")] is source

    def test_local_dir_from_source_stays_eager_by_default(self, tmp_path: Path) -> None:
        src_root = tmp_path / "skills"
        skill_dir = src_root / "dynamic-skill"
        skill_dir.mkdir(parents=True)
        (skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")

        capability = Skills(from_=LocalDir(src=src_root))

        processed = capability.process_manifest(Manifest(root="/workspace"))
        assert processed.entries[Path(".agents")].type == "local_dir"

    def test_lazy_local_dir_source_skips_manifest_materialization(self, tmp_path: Path) -> None:
        src_root = tmp_path / "skills"
        skill_dir = src_root / "dynamic-skill"
        skill_dir.mkdir(parents=True)
        (skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")

        capability = Skills(
            lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
        )

        processed = capability.process_manifest(Manifest(root="/workspace"))
        assert processed.entries == {}

    def test_lazy_local_dir_rejects_overlapping_manifest_entries(self, tmp_path: Path) -> None:
        src_root = tmp_path / "skills"
        skill_dir = src_root / "dynamic-skill"
        skill_dir.mkdir(parents=True)
        (skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")

        capability = Skills(
            lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
        )
        manifest = Manifest(
            root="/workspace",
            entries={Path(".agents"): Dir()},
        )

        with pytest.raises(SkillsConfigError) as exc_info:
            capability.process_manifest(manifest)

        assert exc_info.value.message == "skills lazy_from path overlaps existing manifest entries"
        assert exc_info.value.context == {
            "path": ".agents",
            "source": "lazy_from",
            "overlaps": [".agents"],
        }

    def test_literal_skills_allow_existing_manifest_entry_when_content_matches(self) -> None:
        capability = Skills(
            skills=[
                Skill(
                    name="my-skill",
                    description="desc",
                    content="Use this skill.",
                    scripts={"run.sh": File(content=b"echo run")},
                )
            ]
        )
        rendered_skill = capability.skills[0].as_dir_entry()
        manifest = Manifest(
            root="/workspace",
            entries={".agents/my-skill": rendered_skill},
        )

        processed = capability.process_manifest(manifest)

        assert processed is manifest
        assert processed.entries[".agents/my-skill"] == rendered_skill

    def test_process_manifest_rejects_exact_path_collision(self) -> None:
        capability = Skills(skills=[Skill(name="my-skill", description="desc", content="literal")])
        manifest = Manifest(root="/workspace", entries={Path(".agents/my-skill"): Dir()})

        with pytest.raises(SkillsConfigError):
            capability.process_manifest(manifest)

    def test_custom_skills_path_is_used_for_manifest_entries(self) -> None:
        capability = Skills(
            skills=[Skill(name="my-skill", description="desc", content="literal")],
            skills_path=".sandbox/skills",
        )

        processed = capability.process_manifest(Manifest(root="/workspace"))

        assert processed.entries[Path(".sandbox/skills/my-skill")] == (
            capability.skills[0].as_dir_entry()
        )


class TestSkillsInstructions:
    @pytest.mark.asyncio
    async def test_instructions_include_root_and_literal_index(self) -> None:
        capability = Skills(
            skills=[
                Skill(name="z-skill", description="z description", content="z"),
                Skill(name="a-skill", description="a description", content="a"),
            ]
        )

        instructions = await capability.instructions(Manifest(root="/workspace"))
        assert instructions is not None
        assert instructions.startswith("## Skills\n")
        assert "### Available skills" in instructions
        assert "### How to use skills" in instructions
        assert "- a-skill: a description (file: .agents/a-skill)" in instructions
        assert "- z-skill: z description (file: .agents/z-skill)" in instructions
        assert instructions.index(
            "- a-skill: a description (file: .agents/a-skill)"
        ) < instructions.index("- z-skill: z description (file: .agents/z-skill)")

    @pytest.mark.asyncio
    async def test_instructions_use_custom_skills_path(self) -> None:
        capability = Skills(
            skills=[Skill(name="my-skill", description="desc", content="literal")],
            skills_path=".sandbox/skills",
        )

        instructions = await capability.instructions(Manifest(root="/workspace"))

        assert instructions is not None
        assert "- my-skill: desc (file: .sandbox/skills/my-skill)" in instructions

    @pytest.mark.asyncio
    async def test_instructions_return_none_when_metadata_is_empty(self) -> None:
        capability = Skills(from_=Dir())

        instructions = await capability.instructions(Manifest(root="/workspace"))
        assert instructions is None

    @pytest.mark.asyncio
    async def test_lazy_local_dir_metadata_requires_extra_path_grant(self, tmp_path: Path) -> None:
        src_root = tmp_path / "skills"
        skill_dir = src_root / "dynamic-skill"
        skill_dir.mkdir(parents=True)
        (skill_dir / "SKILL.md").write_text(
            "---\nname: hidden-skill\ndescription: outside base\n---\n# Skill\n",
            encoding="utf-8",
        )
        capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))

        instructions = await capability.instructions(Manifest(root="/workspace"))

        assert instructions is None

    @pytest.mark.asyncio
    async def test_instructions_resolve_from_runtime_frontmatter(self, tmp_path: Path) -> None:
        workspace_root = tmp_path / "workspace"
        workspace_root.mkdir()
        capability = Skills(
            from_=Dir(
                children={
                    "dynamic-skill": Dir(
                        children={
                            "SKILL.md": File(
                                content=(
                                    b"---\n"
                                    b"name: discovered-skill\n"
                                    b"description: loaded from runtime frontmatter\n"
                                    b"---\n\n"
                                    b"# Skill\n"
                                )
                            )
                        }
                    )
                }
            )
        )
        manifest = capability.process_manifest(Manifest(root=str(workspace_root)))
        session = _SkillsSession(manifest)
        await session.apply_manifest()
        capability.bind(session)

        instructions = await capability.instructions(session.state.manifest)

        assert instructions is not None
        assert (
            "- discovered-skill: loaded from runtime frontmatter (file: .agents/dynamic-skill)"
        ) in instructions

    @pytest.mark.asyncio
    async def test_instructions_resolve_opt_in_lazy_local_dir_metadata(
        self, tmp_path: Path
    ) -> None:
        src_root = tmp_path / "skills"
        skill_dir = src_root / "dynamic-skill"
        skill_dir.mkdir(parents=True)
        (skill_dir / "SKILL.md").write_text(
            "---\nname: discovered-skill\ndescription: local dir metadata\n---\n# Skill\n",
            encoding="utf-8",
        )

        capability = Skills(
            lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
        )

        assert await capability.instructions(Manifest(root="/workspace")) is None

        instructions = await capability.instructions(_source_granted_manifest(source=src_root))

        assert instructions is not None
        assert (
            "- discovered-skill: local dir metadata (file: .agents/dynamic-skill)" in instructions
        )
        assert "Call `load_skill` with a single skill name from the list" in instructions
        assert "loaded on demand instead of being present up front" in instructions

    @pytest.mark.asyncio
    async def test_lazy_local_dir_metadata_skips_symlinked_skill_directory(
        self, tmp_path: Path
    ) -> None:
        src_root = tmp_path / "skills"
        outside_root = tmp_path / "outside"
        outside_skill = outside_root / "linked-skill"
        src_root.mkdir()
        outside_skill.mkdir(parents=True)
        (outside_skill / "SKILL.md").write_text(
            "---\nname: linked-skill\ndescription: linked metadata\n---\n# Skill\n",
            encoding="utf-8",
        )
        try:
            (src_root / "linked-skill").symlink_to(outside_skill, target_is_directory=True)
        except OSError as e:
            pytest.skip(f"symlink unavailable: {e}")

        capability = Skills(
            lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
        )

        instructions = await capability.instructions(_source_granted_manifest(source=src_root))

        assert instructions is None

    @pytest.mark.asyncio
    async def test_lazy_local_dir_load_skill_tool_materializes_single_skill(
        self, tmp_path: Path
    ) -> None:
        workspace_root = tmp_path / "workspace"
        workspace_root.mkdir()
        src_root = tmp_path / "skills"
        skill_dir = src_root / "dynamic-skill"
        skill_dir.mkdir(parents=True)
        (skill_dir / "SKILL.md").write_text("# dynamic skill\n", encoding="utf-8")

        capability = Skills(
            lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
        )
        manifest = capability.process_manifest(
            _source_granted_manifest(workspace_root, source=src_root)
        )
        assert manifest.entries == {}

        session = _SkillsSession(manifest)
        capability.bind(session)
        tool = cast(FunctionTool, capability.tools()[0])

        with pytest.raises(FileNotFoundError):
            await session.read(Path(".agents/dynamic-skill/SKILL.md"))

        output = await tool.on_invoke_tool(
            cast(ToolContext[object], None),
            '{"skill_name":"dynamic-skill"}',
        )

        assert output == {
            "status": "loaded",
            "skill_name": "dynamic-skill",
            "path": ".agents/dynamic-skill",
        }
        loaded_skill = workspace_root / ".agents" / "dynamic-skill" / "SKILL.md"
        assert loaded_skill.read_text(encoding="utf-8") == "# dynamic skill\n"


class TestSkillsLazyLoading:
    def test_tools_returns_empty_without_lazy_source(self) -> None:
        capability = Skills(skills=[Skill(name="my-skill", description="desc", content="literal")])

        assert capability.tools() == []

    def test_lazy_tools_require_bound_session(self, tmp_path: Path) -> None:
        src_root = tmp_path / "skills"
        skill_dir = src_root / "dynamic-skill"
        skill_dir.mkdir(parents=True)
        (skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
        capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))

        with pytest.raises(ValueError, match="Skills is not bound to a SandboxSession"):
            capability.tools()

    def test_lazy_tools_expose_load_skill_after_bind(self, tmp_path: Path) -> None:
        workspace_root = tmp_path / "workspace"
        workspace_root.mkdir()
        src_root = tmp_path / "skills"
        skill_dir = src_root / "dynamic-skill"
        skill_dir.mkdir(parents=True)
        (skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
        capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
        capability.bind(_SkillsSession(_source_granted_manifest(workspace_root, source=src_root)))

        tools = capability.tools()

        assert len(tools) == 1
        assert isinstance(tools[0], FunctionTool)
        assert tools[0].name == "load_skill"

    @pytest.mark.asyncio
    async def test_load_skill_rejects_non_lazy_capability(self) -> None:
        capability = Skills(skills=[Skill(name="my-skill", description="desc", content="literal")])

        with pytest.raises(SkillsConfigError):
            await capability.load_skill("my-skill")

    @pytest.mark.asyncio
    async def test_load_skill_returns_already_loaded_for_existing_materialized_skill(
        self, tmp_path: Path
    ) -> None:
        workspace_root = tmp_path / "workspace"
        workspace_root.mkdir()
        src_root = tmp_path / "skills"
        skill_dir = src_root / "dynamic-skill"
        skill_dir.mkdir(parents=True)
        (skill_dir / "SKILL.md").write_text("# dynamic skill\n", encoding="utf-8")
        capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
        session = _SkillsSession(_source_granted_manifest(workspace_root, source=src_root))
        capability.bind(session)
        await session.write(
            Path(".agents/dynamic-skill/SKILL.md"),
            io.BytesIO(b"# already loaded\n"),
        )

        output = await capability.load_skill("dynamic-skill")

        assert output == {
            "status": "already_loaded",
            "skill_name": "dynamic-skill",
            "path": ".agents/dynamic-skill",
        }

    @pytest.mark.asyncio
    async def test_load_skill_materializes_with_bound_run_as_user(self, tmp_path: Path) -> None:
        workspace_root = tmp_path / "workspace"
        workspace_root.mkdir()
        src_root = tmp_path / "skills"
        skill_dir = src_root / "dynamic-skill"
        skill_dir.mkdir(parents=True)
        (skill_dir / "SKILL.md").write_text("# dynamic skill\n", encoding="utf-8")

        capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
        session = _SkillsSession(_source_granted_manifest(workspace_root, source=src_root))
        capability.bind(session)
        capability.bind_run_as(User(name="sandbox-user"))

        output = await capability.load_skill("dynamic-skill")

        assert output == {
            "status": "loaded",
            "skill_name": "dynamic-skill",
            "path": ".agents/dynamic-skill",
        }
        assert session.read_users == ["sandbox-user"]
        assert session.write_users == ["sandbox-user"]
        assert session.mkdir_users
        assert set(session.mkdir_users) == {"sandbox-user"}

    @pytest.mark.asyncio
    async def test_load_skill_rejects_missing_lazy_source_directory(self, tmp_path: Path) -> None:
        workspace_root = tmp_path / "workspace"
        workspace_root.mkdir()
        capability = Skills(
            lazy_from=LocalDirLazySkillSource(source=LocalDir(src=tmp_path / "missing-skills"))
        )
        capability.bind(
            _SkillsSession(
                _source_granted_manifest(workspace_root, source=tmp_path / "missing-skills")
            )
        )

        with pytest.raises(SkillsConfigError):
            await capability.load_skill("missing-skill")

    @pytest.mark.asyncio
    async def test_load_skill_rejects_ambiguous_skill_name(self, tmp_path: Path) -> None:
        workspace_root = tmp_path / "workspace"
        workspace_root.mkdir()
        src_root = tmp_path / "skills"
        first_dir = src_root / "skill-one"
        second_dir = src_root / "skill-two"
        first_dir.mkdir(parents=True)
        second_dir.mkdir(parents=True)
        (first_dir / "SKILL.md").write_text(
            "---\nname: shared-skill\ndescription: first\n---\n# Skill\n",
            encoding="utf-8",
        )
        (second_dir / "SKILL.md").write_text(
            "---\nname: shared-skill\ndescription: second\n---\n# Skill\n",
            encoding="utf-8",
        )
        capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
        capability.bind(_SkillsSession(_source_granted_manifest(workspace_root, source=src_root)))

        with pytest.raises(SkillsConfigError):
            await capability.load_skill("shared-skill")

    @pytest.mark.asyncio
    async def test_lazy_metadata_cache_is_reset_on_bind(self, tmp_path: Path) -> None:
        workspace_root = tmp_path / "workspace"
        workspace_root.mkdir()
        src_root = tmp_path / "skills"
        skill_dir = src_root / "dynamic-skill"
        skill_dir.mkdir(parents=True)
        skill_md = skill_dir / "SKILL.md"
        skill_md.write_text(
            "---\nname: cached-skill\ndescription: old description\n---\n# Skill\n",
            encoding="utf-8",
        )
        capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))

        first_instructions = await capability.instructions(
            _source_granted_manifest(workspace_root, source=src_root)
        )
        skill_md.write_text(
            "---\nname: cached-skill\ndescription: new description\n---\n# Skill\n",
            encoding="utf-8",
        )
        second_instructions = await capability.instructions(
            _source_granted_manifest(workspace_root, source=src_root)
        )
        capability.bind(_SkillsSession(_source_granted_manifest(workspace_root, source=src_root)))
        third_instructions = await capability.instructions(
            _source_granted_manifest(workspace_root, source=src_root)
        )

        assert first_instructions is not None
        assert second_instructions is not None
        assert third_instructions is not None
        assert "- cached-skill: old description (file: .agents/dynamic-skill)" in first_instructions
        assert (
            "- cached-skill: old description (file: .agents/dynamic-skill)" in second_instructions
        )
        assert "- cached-skill: new description (file: .agents/dynamic-skill)" in third_instructions
