"""Unit tests for the tool instance service."""

import hashlib
import uuid
from pathlib import Path
from unittest.mock import MagicMock, AsyncMock

import pytest

from src.services.tool.instance_service import (
    _chown_staged_mounts,
    _get_repository_mount_name,
    _stack_profile_mounts_with_git_mounts,
    _stage_profile_mounts,
    clone_git_repo,
    modify_compose_file,
    prepare_manifest_instance,
    pull_repository_updates,
)


@pytest.mark.unit
class TestModifyComposeFile:
    """Tests for modify_compose_file home-directory expansion."""

    def test_extra_volumes_expand_home_dir(self, tmp_path):
        compose_path = tmp_path / "docker-compose.yml"
        compose_path.write_text(
            "services:\n  app:\n    image: test:latest\n    volumes: []\n"
        )

        modify_compose_file(
            str(compose_path),
            extra_volumes=[
                {"source": "/host/config", "target": "~/.config", "type": "bind"},
                {"source": "/host/code", "target": "$HOME/code", "type": "bind"},
            ],
            home_dir="/home/user",
        )

        content = compose_path.read_text()
        assert "/host/config:/home/user/.config" in content
        assert "/host/code:/home/user/code" in content

    def test_working_directory_expands_home_dir(self, tmp_path):
        compose_path = tmp_path / "docker-compose.yml"
        compose_path.write_text("services:\n  app:\n    image: test:latest\n")

        modify_compose_file(
            str(compose_path),
            working_directory="~/workspace",
            home_dir="/home/user",
        )

        content = compose_path.read_text()
        assert "working_dir: /home/user/workspace" in content


@pytest.mark.unit
class TestGetRepositoryMountName:
    """Tests for _get_repository_mount_name."""

    def test_uses_project_name(self):
        project = MagicMock()
        project.name = "My Project"
        repo = MagicMock()
        assert _get_repository_mount_name(project, repo) == "my-project"

    def test_slugifies_project_name(self):
        project = MagicMock()
        project.name = "Project v2.0!"
        repo = MagicMock()
        assert _get_repository_mount_name(project, repo) == "project-v2-0"


@pytest.mark.unit
class TestCloneGitRepo:
    """Regression tests for reusable config-profile git mount clones."""

    def test_reuses_existing_clone(self, monkeypatch, tmp_path) -> None:
        from src.services.tool import instance_service

        remote_url = "https://gitlab.com/example/dotfiles"
        branch = None
        clone_parent = str(tmp_path)
        url_hash = hashlib.md5(f"{remote_url}:default".encode()).hexdigest()[:12]
        repo_path = tmp_path / "git-mounts" / f"dotfiles-{url_hash}" / "repo-clone"
        (repo_path / ".git").mkdir(parents=True)

        clone = MagicMock(side_effect=AssertionError("existing clone must be reused"))
        pull = MagicMock()
        monkeypatch.setattr(instance_service, "clone_repository", clone)
        monkeypatch.setattr(instance_service, "pull_repository_updates", pull)

        result = clone_git_repo(remote_url, branch, clone_parent)

        assert result == str(repo_path)
        clone.assert_not_called()
        pull.assert_called_once_with(str(repo_path), remote_url)

    def test_refresh_resets_writable_working_copy_to_remote_branch(
        self, monkeypatch
    ) -> None:
        from src.services.tool import instance_service

        results = [
            MagicMock(returncode=0, stdout="", stderr=""),
            MagicMock(returncode=0, stdout="main\n", stderr=""),
            MagicMock(returncode=0, stdout="", stderr=""),
        ]
        run = MagicMock(side_effect=results)
        monkeypatch.setattr(instance_service.subprocess, "run", run)

        pull_repository_updates("/work/profile-git", "https://example.test/repo.git")

        assert run.call_args_list[0].args[0] == [
            "git",
            "-C",
            "/work/profile-git",
            "fetch",
            "origin",
        ]
        assert run.call_args_list[2].args[0] == [
            "git",
            "-C",
            "/work/profile-git",
            "reset",
            "--hard",
            "origin/main",
        ]

    def test_replaces_incomplete_clone_before_retry(
        self, monkeypatch, tmp_path
    ) -> None:
        from src.services.tool import instance_service

        remote_url = "https://gitlab.com/example/dotfiles"
        url_hash = hashlib.md5(f"{remote_url}:main".encode()).hexdigest()[:12]
        clone_dir = tmp_path / "git-mounts" / f"dotfiles-{url_hash}"
        repo_path = clone_dir / "repo-clone"
        repo_path.mkdir(parents=True)
        (repo_path / "partial-file").write_text("incomplete")

        def clone(_url, _key, destination, _branch, project_name=None):
            assert destination == str(clone_dir)
            assert project_name is None
            assert not repo_path.exists()
            (repo_path / ".git").mkdir(parents=True)
            return str(repo_path)

        monkeypatch.setattr(instance_service, "clone_repository", clone)

        result = clone_git_repo(remote_url, "main", str(tmp_path))

        assert result == str(repo_path)
        assert (repo_path / ".git").is_dir()


@pytest.mark.unit
class TestStackProfileMountsWithGitMounts:
    """Tests for composing profile and Git mounts without Docker masking."""

    def test_stages_profile_source_under_instance_directory(self, tmp_path) -> None:
        """Profile sources must be instance-local before ownership is fixed."""
        instance_dir = tmp_path / "instance"
        profile_source = tmp_path / "profile" / "settings.json"
        profile_source.parent.mkdir(parents=True)
        profile_source.write_text("{}")

        staged = _stage_profile_mounts(
            [{"source": str(profile_source), "target": "/home/user/.pi/settings.json"}],
            str(instance_dir),
        )

        assert staged[0]["source"].startswith(str(instance_dir))
        assert staged[0]["source"] != str(profile_source)
        assert Path(staged[0]["source"]).read_text() == "{}"

    def test_staged_profile_source_is_chowned_for_container_user(
        self, monkeypatch, tmp_path
    ) -> None:
        """The ownership pass must include the instance-local profile copy."""
        from src.services.tool import instance_service

        instance_dir = tmp_path / "instance"
        profile_source = tmp_path / "profile"
        profile_source.mkdir()
        (profile_source / "settings.json").write_text("{}")
        staged = _stage_profile_mounts(
            [{"source": str(profile_source), "target": "/home/user/.pi"}],
            str(instance_dir),
        )
        chown = MagicMock()
        monkeypatch.setattr(instance_service, "_chown_path", chown)

        _chown_staged_mounts(staged, str(instance_dir), 1000, 1000)

        chown.assert_called_once_with(staged[0]["source"], 1000, 1000)

    def test_exact_overlap_creates_instance_local_composite(self, tmp_path) -> None:
        """Profile files extend a Git root without mutating its shared clone."""
        instance_dir = tmp_path / "instance"
        git_source = tmp_path / "git" / "repo-clone"
        git_source.mkdir(parents=True)
        (git_source / "existing.txt").write_text("from git")
        profile_source = tmp_path / "profile"
        profile_source.mkdir()
        (profile_source / "settings.json").write_text("{}")

        profile_mounts = _stage_profile_mounts(
            [
                {
                    "source": str(profile_source),
                    "target": "/home/user/.pi",
                    "type": "bind",
                }
            ],
            str(instance_dir),
        )
        result = _stack_profile_mounts_with_git_mounts(
            profile_mounts,
            [{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}],
            str(instance_dir),
        )

        assert len(result) == 2
        assert result[0]["source"] == str(git_source)
        assert result[0]["target"] == "/home/user/.pi"
        assert result[1]["source"].endswith("/settings.json")
        assert result[1]["target"] == "/home/user/.pi/settings.json"
        assert not (git_source / "settings.json").exists()

    def test_descendant_profile_mount_extends_git_root(self, tmp_path) -> None:
        """Nested targets are composed at the Git root, preserving siblings."""
        instance_dir = tmp_path / "instance"
        git_source = tmp_path / "git"
        git_source.mkdir()
        (git_source / "README").write_text("repo")
        profile_source = tmp_path / "profile"
        profile_source.mkdir()
        (profile_source / "settings.json").write_text("x")

        profile_mounts = _stage_profile_mounts(
            [{"source": str(profile_source), "target": "/home/user/.pi/agent"}],
            str(instance_dir),
        )
        result = _stack_profile_mounts_with_git_mounts(
            profile_mounts,
            [{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}],
            str(instance_dir),
        )

        assert len(result) == 2
        assert result[0]["source"] == str(git_source)
        assert result[1]["target"] == "/home/user/.pi/agent/settings.json"
        assert not (git_source / "agent").exists()

    def test_file_profile_mount_extends_git_root(self, tmp_path) -> None:
        """A file bind mount is composed into the Git directory, not masked."""
        instance_dir = tmp_path / "instance"
        git_source = tmp_path / "git"
        git_source.mkdir()
        (git_source / "README").write_text("repo")
        profile_source = tmp_path / "settings.json"
        profile_source.write_text("{}")

        profile_mounts = _stage_profile_mounts(
            [
                {
                    "source": str(profile_source),
                    "target": "/home/user/.pi/settings.json",
                }
            ],
            str(instance_dir),
        )
        result = _stack_profile_mounts_with_git_mounts(
            profile_mounts,
            [{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}],
            str(instance_dir),
        )

        assert len(result) == 2
        assert result[0]["source"] == str(git_source)
        assert result[1]["target"] == "/home/user/.pi/settings.json"

    def test_parent_profile_mount_extends_nested_git_mount(self, tmp_path) -> None:
        """A profile parent mount keeps Git content and its own sibling files."""
        instance_dir = tmp_path / "instance"
        git_source = tmp_path / "git"
        git_source.mkdir()
        (git_source / "plugin.toml").write_text("git")
        profile_source = tmp_path / "profile"
        profile_source.mkdir()
        (profile_source / "config.toml").write_text("profile")

        profile_mounts = _stage_profile_mounts(
            [{"source": str(profile_source), "target": "/home/user"}], str(instance_dir)
        )
        result = _stack_profile_mounts_with_git_mounts(
            profile_mounts,
            [{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}],
            str(instance_dir),
        )

        assert len(result) == 2
        assert result[0]["source"] == str(git_source)
        assert result[1]["target"] == "/home/user/config.toml"

    def test_non_overlapping_mounts_remain_separate(self, tmp_path) -> None:
        """Unrelated profile and Git mounts retain their independent sources."""
        instance_dir = tmp_path / "instance"
        git_source = tmp_path / "git"
        git_source.mkdir()
        profile_source = tmp_path / "profile"
        profile_source.mkdir()

        profile_mounts = _stage_profile_mounts(
            [{"source": str(profile_source), "target": "/home/user/.config"}],
            str(instance_dir),
        )
        git_mounts = [{"source": str(git_source), "target": "/home/user/.pi"}]

        assert (
            _stack_profile_mounts_with_git_mounts(
                profile_mounts, git_mounts, str(instance_dir)
            )
            == git_mounts + profile_mounts
        )


@pytest.mark.unit
async def test_prepare_manifest_instance_uses_workspace_path_basename():
    """WORKSPACE_NAME must be the repo-named workspace directory, not workspace.name."""
    repo = MagicMock()
    repo.name = "src"
    repo.remote_url = "git@git.example.com:acme/headquarter.git"
    repo.path = "/data/repos/main"

    workspace = MagicMock()
    workspace.id = "workspace-uuid"
    workspace.repo_id = "repo-uuid"
    workspace.name = "main"
    workspace.path = "/data/working-copies/workspace-uuid/headquarter"

    tool_type = MagicMock()
    tool_type.name = "pi-agent"
    tool_type.manifest_id = "manifest-uuid"

    manifest_def = MagicMock()
    manifest_def.id = "manifest-uuid"
    manifest_def.manifest = {
        "base_image": "ubuntu:24.04",
        "interface_type": "terminal",
        "user": {"name": "user", "uid": 1001, "gid": 1001},
    }
    manifest_def.base_definition_id = None

    instance = MagicMock()
    instance.id = "instance-uuid"
    instance.name = "pi-agent-headquarter-abc123"
    instance.repository_id = "repo-uuid"
    instance.tool_type_id = "tooltype-uuid"
    instance.port = 0
    instance.selected_config_profile_id = None
    instance.workspace_id = "workspace-uuid"

    session = AsyncMock()

    project = MagicMock()
    project.name = "acme"

    async def session_get(model, obj_id):
        if model.__name__ == "ToolType":
            return tool_type
        if model.__name__ == "ToolDefinitionManifest":
            return manifest_def
        if model.__name__ == "GitRepository":
            return repo
        if model.__name__ == "Workspace":
            return workspace
        if model.__name__ == "Project":
            return project
        return None

    session.get.side_effect = session_get

    # Patch docker images check to report the image already exists so we skip
    # the actual Docker build.
    import subprocess
    from src.services.tool import instance_service

    original_run = subprocess.run

    def fake_run(cmd, **kwargs):
        class Result:
            returncode = 0
            stdout = "image-id"
            stderr = ""

        return Result()

    instance_service.subprocess.run = fake_run
    try:
        (
            image_tag,
            compose_content,
            manifest,
            home_dir,
        ) = await prepare_manifest_instance(
            session=session,
            instance=instance,
            instance_dir="/tmp/instance",
            repo_path="/data/working-copies/workspace-uuid/headquarter",
            env_vars={},
            extra_volumes=[],
            working_directory=None,
        )
    finally:
        instance_service.subprocess.run = original_run

    assert "WORKSPACE_NAME: acme" in compose_content
    assert (
        "/data/working-copies/workspace-uuid/headquarter:/home/user/acme"
        in compose_content
    )


@pytest.mark.unit
async def test_create_tool_instance_resolves_project_when_display_name_given(
    monkeypatch, tmp_path
):
    """Regression: create_tool_instance must resolve `project` even when a
    display_name is supplied.

    Previously `project` was only fetched inside the auto-display-name branch,
    so providing display_name left it unbound and the manifest/dockerfile/compose
    branches crashed with UnboundLocalError at `_get_repository_mount_name`.
    The mount name must derive from the project name regardless.
    """
    from src.schemas.tool import CreateInstanceRequest
    from src.services.tool import instance_service

    project_id = uuid.uuid4()
    repo_id = uuid.uuid4()
    user_id = uuid.uuid4()
    tool_type_id = uuid.uuid4()
    manifest_id = uuid.uuid4()

    repo = MagicMock()
    repo.project_id = project_id
    repo.name = "src"
    repo.path = "/data/repos/src"

    project = MagicMock()
    project.name = "My Project"  # slugifies to "my-project"

    tool_type = MagicMock()
    tool_type.id = tool_type_id
    tool_type.name = "pi-agent"
    tool_type.definition_type = "manifest"
    tool_type.manifest_id = manifest_id
    tool_type.home_directory = None

    manifest_def = MagicMock()
    manifest_def.manifest = {
        "base_image": "ubuntu:24.04",
        "interface_type": "terminal",
        "user": {"name": "user", "uid": 1001, "gid": 1001},
    }
    manifest_def.base_definition_id = None

    async def session_get(model, _obj_id):
        name = getattr(model, "__name__", "")
        if name == "GitRepository":
            return repo
        if name == "Project":
            return project
        if name == "ToolType":
            return tool_type
        if name == "ToolDefinitionManifest":
            return manifest_def
        return None

    session = AsyncMock()
    session.get.side_effect = session_get
    session.add = MagicMock()

    monkeypatch.setattr(
        instance_service, "validate_config_profile", AsyncMock(return_value=None)
    )
    monkeypatch.setattr(
        instance_service, "ensure_instance_directory", lambda _name: str(tmp_path)
    )
    monkeypatch.setattr(instance_service, "find_free_port", lambda: 12345)
    monkeypatch.setattr(
        instance_service, "compute_image_tag", lambda *a, **k: "img:tag"
    )
    compile_compose = MagicMock(return_value="services:\n  app: {}")
    monkeypatch.setattr(instance_service, "compile_compose", compile_compose)
    monkeypatch.setattr(instance_service, "write_compose_file", MagicMock())
    monkeypatch.setattr(instance_service, "publish_lifecycle_event", AsyncMock())

    data = CreateInstanceRequest(
        tool_type_id=str(tool_type_id), display_name="Custom Name"
    )

    instance = await instance_service.create_tool_instance(
        session, user_id, project_id, repo_id, data
    )

    # Must not raise UnboundLocalError; instance returned.
    assert instance is not None

    # The mount name must derive from project.name, proving `project` was
    # resolved on the display_name path (the previous crash site).
    variables = compile_compose.call_args.args[1]
    assert variables["REPO_NAME"] == "my-project"
    assert variables["WORKSPACE_NAME"] == "my-project"
