"""Tests for CLI local context injection."""

from __future__ import annotations

import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from apps.cli.local_context import (
    IGNORE_PATTERNS,
    LocalContextToolset,
    format_local_context,
    get_directory_tree,
    get_git_info,
)


class TestGetGitInfo:
    """Tests for get_git_info()."""

    def test_returns_empty_when_no_git(self, tmp_path: Path) -> None:
        """Non-git directory returns empty dict."""
        result = get_git_info(tmp_path)
        assert result == {} or "branch" in result  # depends on parent git repo

    @patch("apps.cli.local_context._get_git_executable")
    def test_returns_empty_when_git_not_installed(
        self, mock_git: MagicMock, tmp_path: Path
    ) -> None:
        mock_git.return_value = None
        result = get_git_info(tmp_path)
        assert result == {}

    @patch("apps.cli.local_context.subprocess.run")
    @patch("apps.cli.local_context._get_git_executable")
    def test_returns_branch_info(
        self, mock_git: MagicMock, mock_run: MagicMock, tmp_path: Path
    ) -> None:
        mock_git.return_value = "/usr/bin/git"
        mock_run.side_effect = [
            MagicMock(returncode=0, stdout="feature-xyz\n"),
            MagicMock(returncode=0, stdout="  main\n* feature-xyz\n"),
            MagicMock(returncode=0, stdout="M file.py\n"),
        ]

        result = get_git_info(tmp_path)
        assert result["branch"] == "feature-xyz"
        assert "main" in result["main_branches"]
        assert result["uncommitted"] == 1

    @patch("apps.cli.local_context.subprocess.run")
    @patch("apps.cli.local_context._get_git_executable")
    def test_handles_non_git_directory(
        self, mock_git: MagicMock, mock_run: MagicMock, tmp_path: Path
    ) -> None:
        mock_git.return_value = "/usr/bin/git"
        mock_run.return_value = MagicMock(returncode=128, stdout="")

        result = get_git_info(tmp_path)
        assert result == {}

    @patch("apps.cli.local_context.subprocess.run")
    @patch("apps.cli.local_context._get_git_executable")
    def test_handles_timeout(
        self, mock_git: MagicMock, mock_run: MagicMock, tmp_path: Path
    ) -> None:
        mock_git.return_value = "/usr/bin/git"
        mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=2)

        result = get_git_info(tmp_path)
        assert result == {}

    @patch("apps.cli.local_context.subprocess.run")
    @patch("apps.cli.local_context._get_git_executable")
    def test_branch_list_fails(
        self, mock_git: MagicMock, mock_run: MagicMock, tmp_path: Path
    ) -> None:
        """Branch list returning non-zero should still return branch info."""
        mock_git.return_value = "/usr/bin/git"
        mock_run.side_effect = [
            MagicMock(returncode=0, stdout="feature\n"),
            MagicMock(returncode=1, stdout=""),
            MagicMock(returncode=0, stdout=""),
        ]
        result = get_git_info(tmp_path)
        assert result["branch"] == "feature"
        assert result["main_branches"] == []
        assert result["uncommitted"] == 0


class TestGetDirectoryTree:
    """Tests for get_directory_tree()."""

    def test_empty_directory(self, tmp_path: Path) -> None:
        result = get_directory_tree(tmp_path)
        assert result == ""

    def test_root_files_and_folder_counts(self, tmp_path: Path) -> None:
        """Folders-first: root files are listed; nested files are counted, not
        individually listed."""
        (tmp_path / "file1.py").write_text("content")
        (tmp_path / "file2.txt").write_text("content")
        (tmp_path / "subdir").mkdir()
        (tmp_path / "subdir" / "nested.py").write_text("content")

        result = get_directory_tree(tmp_path)
        assert "file1.py" in result
        assert "file2.txt" in result
        assert "subdir/" in result
        assert "(1 file)" in result  # nested.py counted
        assert "nested.py" not in result  # not listed individually

    def test_ignores_patterns(self, tmp_path: Path) -> None:
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "app.py").write_text("content")
        (tmp_path / "node_modules").mkdir()
        (tmp_path / "node_modules" / "pkg").mkdir()
        (tmp_path / "__pycache__").mkdir()
        (tmp_path / ".git").mkdir()

        result = get_directory_tree(tmp_path)
        assert "src/" in result
        assert "node_modules" not in result
        assert "__pycache__" not in result
        assert ".git" not in result

    def test_ignores_hidden_dirs_and_custom_named_venv(self, tmp_path: Path) -> None:
        """A custom-named virtualenv (`.venv310`) and any hidden dir are skipped
        — detected by the `pyvenv.cfg` marker, not a hard-coded name."""
        (tmp_path / "src").mkdir()
        venv = tmp_path / ".venv310"
        (venv / "bin").mkdir(parents=True)
        (venv / "pyvenv.cfg").write_text("home = /usr/bin\n")
        (venv / "bin" / "activate").write_text("")
        (tmp_path / ".secret").mkdir()

        result = get_directory_tree(tmp_path)
        assert "src/" in result
        assert ".venv310" not in result
        assert "activate" not in result
        assert ".secret" not in result

    def test_max_depth(self, tmp_path: Path) -> None:
        current = tmp_path
        for i in range(10):
            current = current / f"level{i}"
            current.mkdir()
            (current / f"file{i}.txt").write_text("")

        result = get_directory_tree(tmp_path, max_depth=2)
        assert "level0/" in result
        assert "level1/" in result
        assert "level5/" not in result

    def test_per_dir_cap_marks_overflow(self, tmp_path: Path) -> None:
        parent = tmp_path / "parent"
        parent.mkdir()
        for i in range(30):
            (parent / f"child{i:02d}").mkdir()

        result = get_directory_tree(tmp_path, max_per_dir=5)
        assert "more dirs" in result

    def test_all_top_level_dirs_shown(self, tmp_path: Path) -> None:
        """Top-level directories are the backbone — none get dropped even when
        an earlier sibling is large."""
        big = tmp_path / "aaa_big"
        big.mkdir()
        for i in range(40):
            (big / f"sub{i:02d}").mkdir()
        (tmp_path / "zzz_pkg").mkdir()

        result = get_directory_tree(tmp_path, max_entries=10)
        assert "aaa_big/" in result
        assert "zzz_pkg/" in result  # not starved by the big sibling

    def test_permission_error(self, tmp_path: Path) -> None:
        """Unreadable directory should be silently skipped."""
        (tmp_path / "readable").mkdir()
        (tmp_path / "readable" / "child").mkdir()
        unreadable = tmp_path / "unreadable"
        unreadable.mkdir()
        (unreadable / "secret").mkdir()
        unreadable.chmod(0o000)

        try:
            result = get_directory_tree(tmp_path)
            assert "readable/" in result
            assert "secret" not in result
        finally:
            unreadable.chmod(0o755)


class TestFormatLocalContext:
    """Tests for format_local_context()."""

    def test_with_git_and_tree(self, tmp_path: Path) -> None:
        git_info = {"branch": "main", "main_branches": ["main"]}
        tree = "├── src/\n│   └── app.py\n└── README.md"

        result = format_local_context(tmp_path, git_info, tree)
        assert "### Local Context" in result
        assert "**Git branch**: `main`" in result
        assert "**Main branch**: `main`" in result
        assert "src/" in result
        assert "app.py" in result

    def test_without_git(self, tmp_path: Path) -> None:
        result = format_local_context(tmp_path, {}, "└── file.txt")
        assert "### Local Context" in result
        assert "Git branch" not in result
        assert "file.txt" in result

    def test_empty(self, tmp_path: Path) -> None:
        result = format_local_context(tmp_path, {}, "")
        assert "### Local Context" in result

    def test_git_info_without_main_branches(self, tmp_path: Path) -> None:
        result = format_local_context(
            tmp_path, {"branch": "develop", "main_branches": []}, "└── app.py"
        )
        assert "**Git branch**: `develop`" in result
        assert "Main branch" not in result

    def test_empty_tree_with_git(self, tmp_path: Path) -> None:
        result = format_local_context(tmp_path, {"branch": "main"}, "")
        assert "**Git branch**: `main`" in result
        assert "Directory structure" not in result


class TestLocalContextToolset:
    """Tests for LocalContextToolset."""

    def test_creates_with_default_path(self) -> None:
        toolset = LocalContextToolset()
        assert toolset._root == Path.cwd()

    def test_creates_with_custom_path(self, tmp_path: Path) -> None:
        toolset = LocalContextToolset(root_dir=tmp_path)
        assert toolset._root == tmp_path

    @pytest.mark.anyio
    async def test_get_instructions_returns_list(self, tmp_path: Path) -> None:
        (tmp_path / "test.py").write_text("print('hello')")
        toolset = LocalContextToolset(root_dir=tmp_path)

        ctx = MagicMock()
        result = await toolset.get_instructions(ctx)
        assert isinstance(result, list)
        joined = "\n\n".join(part.content for part in result)
        assert "### Local Context" in joined
        assert "test.py" in joined

    @pytest.mark.anyio
    async def test_caches_context(self, tmp_path: Path) -> None:
        toolset = LocalContextToolset(root_dir=tmp_path)
        ctx = MagicMock()

        result1 = await toolset.get_instructions(ctx)
        result2 = await toolset.get_instructions(ctx)
        assert result1 == result2
        assert toolset._cached_context is not None


class TestIgnorePatterns:
    """Tests for IGNORE_PATTERNS constant."""

    def test_contains_common_patterns(self) -> None:
        assert ".git" in IGNORE_PATTERNS
        assert "node_modules" in IGNORE_PATTERNS
        assert "__pycache__" in IGNORE_PATTERNS
        assert ".venv" in IGNORE_PATTERNS
        assert "dist" in IGNORE_PATTERNS
        assert "build" in IGNORE_PATTERNS
