"""
Unit tests for gitignore handling in file operations.

Recent refactoring:
- Test helpers moved to conftest.py for reusability across test modules
- Imports reorganized to reflect sync_engine module structure
- Added comprehensive nested gitignore tests with parametrization
- Added pytest skipif marker for git availability checks
"""

import subprocess
import sys
import tempfile
from collections.abc import Generator
from pathlib import Path

try:
    import pytest

    PYTEST_AVAILABLE = True
except ImportError:
    PYTEST_AVAILABLE = False

# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))

# Import test helpers from conftest
from persuade.categorization import should_skip
from persuade.constants import REQUIRED_GITIGNORE_ENTRIES, FileCategory
from persuade.file_ops import FileInfo, _is_file_type_mismatch
from persuade.git_ops import update_gitignore, validate_gitignore_compliance
from persuade.marker_ops import enable_shadow_mode, is_shadow_mode_enabled
from persuade.symlink_ops import create_symlink_with_fallback, validate_source_path

from .conftest import check_git_available, init_git_repo

# Skip all tests if git is not available
if PYTEST_AVAILABLE:
    pytestmark = pytest.mark.skipif(not check_git_available(), reason="Git is not available")

if PYTEST_AVAILABLE:

    @pytest.mark.parametrize("dirname", ["state", "logs", ".backup"])
    def test_nested_gitignore_per_directory(dirname: str, tmp_path: Path) -> None:
        """Test that nested .gitignore properly ignores files in runtime directories.

        This is a fast, focused unit test that validates each directory independently.
        For integration testing that validates all three directories together plus
        git status verification, see test_nested_gitignore_files_prevent_tracking.

        Parametrized approach provides:
        - Cleaner test reporting (see exactly which directory fails)
        - Faster execution (runs in parallel with pytest-xdist)
        - Easier debugging when specific directories have issues

        Args:
            dirname: The runtime directory name to test (state, logs, or .backup)
            tmp_path: Pytest fixture providing temporary directory
        """
        target_path = tmp_path
        init_git_repo(target_path)

        # Create runtime directory with nested .gitignore
        dir_path = target_path / ".claude" / dirname
        dir_path.mkdir(parents=True, exist_ok=True)

        gitignore_path = dir_path / ".gitignore"
        gitignore_path.write_text("*\n!.gitignore\n")

        # Add and commit the .gitignore
        subprocess.run(
            ["git", "add", str(gitignore_path)],
            cwd=target_path,
            capture_output=True,
            check=True,
        )
        subprocess.run(
            ["git", "commit", "-m", "Add nested gitignore"],
            cwd=target_path,
            capture_output=True,
            check=True,
        )

        # Create runtime file in this directory
        runtime_file = dir_path / f"test_{dirname}.json"
        runtime_file.write_text('{"test": "data"}')

        # Verify runtime file is ignored
        result = subprocess.run(
            ["git", "check-ignore", str(runtime_file)],
            cwd=target_path,
            capture_output=True,
        )
        assert result.returncode == 0, f"Expected {runtime_file.name} to be ignored"

        # Verify .gitignore itself is NOT ignored
        result = subprocess.run(
            ["git", "check-ignore", str(gitignore_path)],
            cwd=target_path,
            capture_output=True,
        )
        assert result.returncode == 1, f"Expected {dirname}/.gitignore to NOT be ignored"

        # Verify runtime file doesn't appear in git status
        status_result = subprocess.run(
            ["git", "status", "--porcelain"],
            cwd=target_path,
            capture_output=True,
            text=True,
            check=True,
        )
        assert runtime_file.name not in status_result.stdout, (
            f"Expected {runtime_file.name} to not appear in git status"
        )

    @pytest.fixture
    def git_repo(tmp_path: Path) -> Generator[Path, None, None]:
        """Create initialized git repo with test config (pytest fixture).

        Yields:
            Path to initialized git repository
        """
        init_git_repo(tmp_path)
        yield tmp_path


def test_validate_gitignore_compliance_clean_repo() -> None:
    """Test validation with no tracked files that should be ignored."""
    with tempfile.TemporaryDirectory() as tmpdir:
        target_path = Path(tmpdir)
        init_git_repo(target_path)

        # Create .claude directory
        (target_path / ".claude").mkdir()

        # Create files but DON'T track them
        (target_path / ".claude" / "settings.local.json").write_text("{}")
        (target_path / ".claude" / "state").mkdir()
        (target_path / ".claude" / "state" / "test.txt").write_text("test")

        # Validate - should find NO violations since files aren't tracked
        violations = validate_gitignore_compliance(target_path)

        assert violations == [], f"Expected no violations, got: {violations}"
        print("✓ test_validate_gitignore_compliance_clean_repo passed")


def test_validate_gitignore_compliance_with_tracked_files() -> None:
    """Test detection of tracked files that should be ignored."""
    with tempfile.TemporaryDirectory() as tmpdir:
        target_path = Path(tmpdir)
        init_git_repo(target_path)

        # Create .claude directory
        (target_path / ".claude").mkdir()

        # Create and TRACK files that should be ignored
        settings_local = target_path / ".claude" / "settings.local.json"
        settings_local.write_text("{}")
        subprocess.run(
            ["git", "add", str(settings_local)],
            cwd=target_path,
            capture_output=True,
            check=True,
        )
        subprocess.run(
            ["git", "commit", "-m", "test"],
            cwd=target_path,
            capture_output=True,
            check=True,
        )

        # Validate - should find violations
        violations = validate_gitignore_compliance(target_path)

        assert len(violations) > 0, "Expected to find violations"
        assert ".claude/settings.local.json" in violations, (
            f"Expected settings.local.json in violations, got: {violations}"
        )
        print("✓ test_validate_gitignore_compliance_with_tracked_files passed")


def test_validate_gitignore_compliance_directory_pattern() -> None:
    """Test detection of tracked files in directories."""
    with tempfile.TemporaryDirectory() as tmpdir:
        target_path = Path(tmpdir)
        init_git_repo(target_path)

        # Create .claude directory
        (target_path / ".claude").mkdir()
        (target_path / ".claude" / "state").mkdir()

        # Create and TRACK file in state directory
        state_file = target_path / ".claude" / "state" / "test.json"
        state_file.write_text("{}")
        subprocess.run(
            ["git", "add", str(state_file)],
            cwd=target_path,
            capture_output=True,
            check=True,
        )
        subprocess.run(
            ["git", "commit", "-m", "test"],
            cwd=target_path,
            capture_output=True,
            check=True,
        )

        # Validate - should find violations for directory pattern
        violations = validate_gitignore_compliance(target_path)

        assert len(violations) > 0, "Expected to find violations in state directory"
        assert ".claude/state/test.json" in violations, (
            f"Expected state/test.json in violations, got: {violations}"
        )
        print("✓ test_validate_gitignore_compliance_directory_pattern passed")


def test_validate_gitignore_compliance_no_git_repo() -> None:
    """Test graceful handling when not in a git repository."""
    with tempfile.TemporaryDirectory() as tmpdir:
        target_path = Path(tmpdir)

        # Don't initialize git - just create .claude directory
        (target_path / ".claude").mkdir()
        (target_path / ".claude" / "settings.local.json").write_text("{}")

        # Validate - should handle gracefully and return empty list
        violations = validate_gitignore_compliance(target_path)

        assert violations == [], f"Expected empty list for non-git repo, got: {violations}"
        print("✓ test_validate_gitignore_compliance_no_git_repo passed")


def test_update_gitignore_adds_missing_entries() -> None:
    """Test that missing gitignore entries are added."""
    with tempfile.TemporaryDirectory() as tmpdir:
        target_path = Path(tmpdir)
        gitignore_path = target_path / ".gitignore"

        # Create empty .gitignore
        gitignore_path.write_text("# Existing content\n*.pyc\n")

        # Update gitignore
        added = update_gitignore(target_path)

        # Should have added all required entries
        assert len(added) == len(REQUIRED_GITIGNORE_ENTRIES), (
            f"Expected {len(REQUIRED_GITIGNORE_ENTRIES)} additions, got {len(added)}"
        )

        # Check content
        content = gitignore_path.read_text()
        for entry in REQUIRED_GITIGNORE_ENTRIES:
            assert entry in content, f"Expected {entry} in .gitignore"

        # Original content should be preserved
        assert "*.pyc" in content, "Expected existing content to be preserved"

        print("✓ test_update_gitignore_adds_missing_entries passed")


def test_update_gitignore_preserves_existing_entries() -> None:
    """Test that existing gitignore entries are not duplicated."""
    with tempfile.TemporaryDirectory() as tmpdir:
        target_path = Path(tmpdir)
        gitignore_path = target_path / ".gitignore"

        # Create .gitignore with existing Claude entries
        existing_content = """# Existing content
*.pyc
.claude/settings.local.json
.claude/state/
"""
        gitignore_path.write_text(existing_content)

        # Update gitignore
        added = update_gitignore(target_path)

        # Verify specific required entries were added.
        # We intentionally use a subset check rather than asserting an exact
        # count or exact set equality: update_gitignore is allowed to add
        # additional ignore entries over time, and this test only cares that
        # at least these entries are newly added while existing ones are not
        # duplicated.
        expected_additions = {".claude/logs/", ".claude/.backup/"}
        assert expected_additions.issubset(set(added)), (
            f"Expected {expected_additions} to be added, got: {added}"
        )

        # Check that existing entries weren't duplicated
        content = gitignore_path.read_text()
        assert content.count(".claude/settings.local.json") == 1, "Entry should not be duplicated"
        assert content.count(".claude/state/") == 1, "Entry should not be duplicated"

        print("✓ test_update_gitignore_preserves_existing_entries passed")


def test_update_gitignore_creates_file_if_missing() -> None:
    """Test that .gitignore is created if it doesn't exist."""
    with tempfile.TemporaryDirectory() as tmpdir:
        target_path = Path(tmpdir)
        gitignore_path = target_path / ".gitignore"

        # Don't create .gitignore
        assert not gitignore_path.exists()

        # Update gitignore
        added = update_gitignore(target_path)

        # Should have been created with all entries
        assert gitignore_path.exists(), ".gitignore should be created"
        assert len(added) == len(REQUIRED_GITIGNORE_ENTRIES), (
            f"Expected {len(REQUIRED_GITIGNORE_ENTRIES)} additions"
        )

        # Check content
        content = gitignore_path.read_text()
        for entry in REQUIRED_GITIGNORE_ENTRIES:
            assert entry in content, f"Expected {entry} in .gitignore"

        print("✓ test_update_gitignore_creates_file_if_missing passed")


def test_update_gitignore_returns_correct_additions() -> None:
    """Test that update_gitignore returns the list of entries added."""
    with tempfile.TemporaryDirectory() as tmpdir:
        target_path = Path(tmpdir)
        gitignore_path = target_path / ".gitignore"

        # Create .gitignore with one existing entry
        gitignore_path.write_text(".claude/settings.local.json\n")

        # Update gitignore
        added = update_gitignore(target_path)

        # Should return only the newly added entries
        assert ".claude/settings.local.json" not in added, (
            "Existing entry should not be in additions"
        )
        assert ".claude/state/" in added, "New entry should be in additions"
        assert ".claude/logs/" in added, "New entry should be in additions"

        print("✓ test_update_gitignore_returns_correct_additions passed")


def test_enable_shadow_mode_creates_exclude_entry() -> None:
    """Test that shadow mode adds entries to .git/info/exclude."""
    with tempfile.TemporaryDirectory() as tmpdir:
        target_path = Path(tmpdir)
        init_git_repo(target_path)

        additions, commands = enable_shadow_mode(target_path, [".github/workflows/ci.yml"])

        assert ".claude/" in additions, "Should add .claude/ to exclude"
        exclude_path = target_path / ".git" / "info" / "exclude"
        assert exclude_path.exists()
        exclude_content = exclude_path.read_text()
        assert ".claude/" in exclude_content, "Exclude should contain .claude/"
        assert "shadow mode" in exclude_content.lower(), "Should have shadow mode comment"

        print("✓ test_enable_shadow_mode_creates_exclude_entry passed")


def test_enable_shadow_mode_creates_marker_file() -> None:
    """Test that shadow mode creates .shadow marker."""
    with tempfile.TemporaryDirectory() as tmpdir:
        target_path = Path(tmpdir)

        enable_shadow_mode(target_path)

        marker = target_path / ".claude" / ".shadow"
        assert marker.exists(), "Shadow marker file should exist"
        marker_content = marker.read_text()
        assert "Shadow mode enabled" in marker_content, "Marker should contain status message"

        print("✓ test_enable_shadow_mode_creates_marker_file passed")


def test_enable_shadow_mode_detects_tracked_files() -> None:
    """Test that shadow mode detects already-tracked files."""
    with tempfile.TemporaryDirectory() as tmpdir:
        target_path = Path(tmpdir)
        init_git_repo(target_path)

        # Create and track .claude/ files
        claude_file = target_path / ".claude" / "test.txt"
        claude_file.parent.mkdir()
        claude_file.write_text("test")
        subprocess.run(
            ["git", "add", ".claude/"],
            cwd=target_path,
            capture_output=True,
            check=True,
        )

        additions, commands = enable_shadow_mode(target_path)

        # Issue #157 fix: when files are staged, unstage command comes first
        assert len(commands) == 3, "Should provide unstage + untrack commands"
        assert "git restore --staged" in commands[0], "First command should unstage"
        assert "git rm" in commands[1], "Second command should be git rm"
        assert "git commit" in commands[2], "Third command should be git commit"

        print("✓ test_enable_shadow_mode_detects_tracked_files passed")


def test_is_shadow_mode_enabled_returns_true_when_marker_exists() -> None:
    """Test shadow mode detection."""
    with tempfile.TemporaryDirectory() as tmpdir:
        target_path = Path(tmpdir)
        marker = target_path / ".claude" / ".shadow"
        marker.parent.mkdir(parents=True)
        marker.touch()

        result = is_shadow_mode_enabled(target_path)

        assert result is True, "Should detect shadow mode when marker exists"

        print("✓ test_is_shadow_mode_enabled_returns_true_when_marker_exists passed")


def test_is_shadow_mode_enabled_returns_false_when_marker_missing() -> None:
    """Test shadow mode detection when disabled."""
    with tempfile.TemporaryDirectory() as tmpdir:
        target_path = Path(tmpdir)

        result = is_shadow_mode_enabled(target_path)

        assert result is False, "Should return False when marker doesn't exist"

        print("✓ test_is_shadow_mode_enabled_returns_false_when_marker_missing passed")


def test_enable_shadow_mode_skips_existing_gitignore_entries() -> None:
    """Test that shadow mode doesn't duplicate existing .claude/ entries."""
    with tempfile.TemporaryDirectory() as tmpdir:
        target_path = Path(tmpdir)
        gitignore_path = target_path / ".gitignore"
        # Pre-existing .claude/ entry
        gitignore_path.write_text("# Existing\n.claude/\n")

        additions, commands = enable_shadow_mode(target_path)

        assert ".claude/" not in additions, "Should not add duplicate entry"
        # Verify only one .claude/ entry exists
        content = gitignore_path.read_text()
        assert content.count(".claude/") == 1, "Should have exactly one .claude/ entry"

        print("✓ test_enable_shadow_mode_skips_existing_gitignore_entries passed")


def test_should_skip_sync_tool_internals() -> None:
    """Test that sync tool meta-files are properly skipped."""
    with tempfile.TemporaryDirectory() as tmpdir:
        base_path = Path(tmpdir)

        # Test cases for sync tool files that should be skipped
        # These are the persuade package and tests directories at project root
        test_cases = [
            "persuade/cli.py",
            "persuade/validators.py",
            "persuade/file_ops.py",
            "persuade/nested/module.py",
            "tests/test_file_ops.py",
            "tests/unit/test_something.py",
        ]

        for test_path in test_cases:
            path = base_path / test_path
            path.parent.mkdir(parents=True, exist_ok=True)
            path.touch()

            result = should_skip(path, base_path)
            assert result is True, f"Expected {test_path} to be skipped, but it wasn't"

        # Verify other scripts are NOT skipped
        normal_scripts = [
            ".claude/scripts/my-script.sh",
            ".claude/scripts/check-pr-reviews.sh",
            ".claude/commands/my-command.md",
        ]

        for test_path in normal_scripts:
            path = base_path / test_path
            path.parent.mkdir(parents=True, exist_ok=True)
            path.touch()

            result = should_skip(path, base_path)
            assert result is False, f"Expected {test_path} NOT to be skipped, but it was"

        print("✓ test_should_skip_sync_tool_internals passed")


def test_nested_gitignore_files_prevent_tracking() -> None:
    """Test that nested .gitignore files properly ignore runtime files.

    Creates .gitignore files with '*' and '!.gitignore' patterns in runtime
    directories (.claude/state, .claude/logs, .claude/.backup), then verifies
    that runtime files are ignored while .gitignore files themselves remain tracked.

    This validates the nested approach is more robust than root-level patterns.
    """
    with tempfile.TemporaryDirectory() as tmpdir:
        target_path = Path(tmpdir)
        init_git_repo(target_path)

        # Create runtime directories with nested .gitignore files
        for dirname in ["state", "logs", ".backup"]:
            dir_path = target_path / ".claude" / dirname
            dir_path.mkdir(parents=True, exist_ok=True)

            # Create nested .gitignore
            gitignore_path = dir_path / ".gitignore"
            gitignore_path.write_text("*\n!.gitignore\n")

            # Add and commit the .gitignore
            subprocess.run(
                ["git", "add", str(gitignore_path)],
                cwd=target_path,
                capture_output=True,
                check=True,
            )

        subprocess.run(
            ["git", "commit", "-m", "Add nested gitignore files"],
            cwd=target_path,
            capture_output=True,
            check=True,
        )

        # Create runtime files that should be ignored
        runtime_files = [
            target_path / ".claude" / "state" / "pr_monitor_state.json",
            target_path / ".claude" / "logs" / "sync_debug.log",
            target_path / ".claude" / ".backup" / "settings_backup.tar",
        ]

        for file_path in runtime_files:
            file_path.write_text("test content")

        # Try to add runtime files - should be ignored by git
        for file_path in runtime_files:
            result = subprocess.run(
                ["git", "check-ignore", str(file_path)],
                cwd=target_path,
                capture_output=True,
            )
            # Exit code 0 means file IS ignored
            assert result.returncode == 0, f"Expected {file_path.name} to be ignored by git"

        # Verify files don't appear in git status (stronger validation)
        status_result = subprocess.run(
            ["git", "status", "--porcelain"],
            cwd=target_path,
            capture_output=True,
            text=True,
            check=True,
        )
        status_output = status_result.stdout

        # Runtime files should NOT appear in status output
        for file_path in runtime_files:
            assert file_path.name not in status_output, (
                f"Expected {file_path.name} to not appear in git status"
            )

        # Verify the .gitignore files themselves are NOT ignored
        for dirname in ["state", "logs", ".backup"]:
            gitignore_path = target_path / ".claude" / dirname / ".gitignore"
            result = subprocess.run(
                ["git", "check-ignore", str(gitignore_path)],
                cwd=target_path,
                capture_output=True,
            )
            # Exit code 1 means file is NOT ignored (should be tracked)
            assert result.returncode == 1, f"Expected {dirname}/.gitignore to NOT be ignored"

        print("✓ test_nested_gitignore_files_prevent_tracking passed")


def test_nested_gitignore_handles_subdirectories() -> None:
    """Test that nested .gitignore ignores files in subdirectories too.

    Edge case: Verifies that the wildcard pattern '*' in nested .gitignore
    files also matches files in subdirectories within the runtime directories.
    """
    with tempfile.TemporaryDirectory() as tmpdir:
        target_path = Path(tmpdir)
        init_git_repo(target_path)

        # Create nested .gitignore in state directory
        state_dir = target_path / ".claude" / "state"
        state_dir.mkdir(parents=True, exist_ok=True)

        gitignore_path = state_dir / ".gitignore"
        gitignore_path.write_text("*\n!.gitignore\n")

        # Commit the .gitignore
        subprocess.run(
            ["git", "add", str(gitignore_path)],
            cwd=target_path,
            capture_output=True,
            check=True,
        )
        subprocess.run(
            ["git", "commit", "-m", "Add nested gitignore"],
            cwd=target_path,
            capture_output=True,
            check=True,
        )

        # Create files in subdirectory
        subdir = state_dir / "monitoring"
        subdir.mkdir(exist_ok=True)

        nested_file = subdir / "pr_data.json"
        nested_file.write_text('{"test": "data"}')

        deeply_nested_file = subdir / "archive" / "old_data.json"
        deeply_nested_file.parent.mkdir(exist_ok=True)
        deeply_nested_file.write_text('{"archived": true}')

        # Verify files in subdirectories are also ignored
        for file_path in [nested_file, deeply_nested_file]:
            result = subprocess.run(
                ["git", "check-ignore", str(file_path)],
                cwd=target_path,
                capture_output=True,
            )
            assert result.returncode == 0, (
                f"Expected {file_path.relative_to(state_dir)} to be ignored"
            )

        # Verify files don't appear in git status
        status_result = subprocess.run(
            ["git", "status", "--porcelain"],
            cwd=target_path,
            capture_output=True,
            text=True,
            check=True,
        )

        # Neither file should appear in status
        assert "pr_data.json" not in status_result.stdout, (
            "Expected pr_data.json to not appear in git status"
        )
        assert "old_data.json" not in status_result.stdout, (
            "Expected old_data.json to not appear in git status"
        )

        print("✓ test_nested_gitignore_handles_subdirectories passed")


# =============================================================================
# Unit tests for _is_file_type_mismatch helper function (mode transition detection)
# =============================================================================


class TestFileTypeMismatchDetection:
    """Unit tests for the _is_file_type_mismatch helper function.

    These tests validate the mode transition detection logic:
    - Editable mode (use_symlinks=True): expects symlinks for SYNC_ALWAYS files
    - Normal mode (use_symlinks=False): expects regular files (copies)
    """

    def test_mismatch_copy_when_editable_mode(self, tmp_path: Path) -> None:
        """Regular file when editable mode expects symlink should be detected as mismatch."""
        target_file = tmp_path / "test.txt"
        target_file.write_text("content")

        file_info = FileInfo(category=FileCategory.SYNC_ALWAYS, destination=None)

        # Editable mode (use_symlinks=True) expects symlink, but file is regular
        result = _is_file_type_mismatch(target_file, use_symlinks=True, file_info=file_info)

        assert result is True, "Should detect mismatch: regular file when symlink expected"

    def test_mismatch_symlink_when_normal_mode(self, tmp_path: Path) -> None:
        """Symlink when normal mode expects regular file should be detected as mismatch."""
        source_file = tmp_path / "source.txt"
        source_file.write_text("content")

        target_file = tmp_path / "test.txt"
        target_file.symlink_to(source_file)

        file_info = FileInfo(category=FileCategory.SYNC_ALWAYS, destination=None)

        # Normal mode (use_symlinks=False) expects regular file, but file is symlink
        result = _is_file_type_mismatch(target_file, use_symlinks=False, file_info=file_info)

        assert result is True, "Should detect mismatch: symlink when regular file expected"

    def test_no_mismatch_symlink_in_editable_mode(self, tmp_path: Path) -> None:
        """Symlink in editable mode should not be a mismatch."""
        source_file = tmp_path / "source.txt"
        source_file.write_text("content")

        target_file = tmp_path / "test.txt"
        target_file.symlink_to(source_file)

        file_info = FileInfo(category=FileCategory.SYNC_ALWAYS, destination=None)

        # Editable mode expects symlink, file is symlink - no mismatch
        result = _is_file_type_mismatch(target_file, use_symlinks=True, file_info=file_info)

        assert result is False, "Should not be mismatch: symlink when symlink expected"

    def test_no_mismatch_copy_in_normal_mode(self, tmp_path: Path) -> None:
        """Regular file in normal mode should not be a mismatch."""
        target_file = tmp_path / "test.txt"
        target_file.write_text("content")

        file_info = FileInfo(category=FileCategory.SYNC_ALWAYS, destination=None)

        # Normal mode expects regular file, file is regular - no mismatch
        result = _is_file_type_mismatch(target_file, use_symlinks=False, file_info=file_info)

        assert result is False, "Should not be mismatch: regular file when regular expected"

    def test_no_mismatch_file_not_exists(self, tmp_path: Path) -> None:
        """Non-existent file should not be a mismatch."""
        target_file = tmp_path / "nonexistent.txt"
        file_info = FileInfo(category=FileCategory.SYNC_ALWAYS, destination=None)

        result = _is_file_type_mismatch(target_file, use_symlinks=True, file_info=file_info)

        assert result is False, "Non-existent file should not be a mismatch"

    def test_no_mismatch_non_symlinkable_category(self, tmp_path: Path) -> None:
        """Files that can't be symlinked should never be a mismatch."""
        target_file = tmp_path / "test.txt"
        target_file.write_text("content")

        # COPY_IF_MISSING files are never symlinked
        file_info = FileInfo(category=FileCategory.COPY_IF_MISSING, destination=None)

        # Even in editable mode, this file type is always copied
        result = _is_file_type_mismatch(target_file, use_symlinks=True, file_info=file_info)

        assert result is False, "Non-symlinkable category should never be a mismatch"

    def test_no_mismatch_merge_smart_category(self, tmp_path: Path) -> None:
        """MERGE_SMART files (settings.json) should never be a mismatch."""
        target_file = tmp_path / "settings.json"
        target_file.write_text("{}")

        file_info = FileInfo(category=FileCategory.MERGE_SMART, destination=None)

        result = _is_file_type_mismatch(target_file, use_symlinks=True, file_info=file_info)

        assert result is False, "MERGE_SMART files should never be a mismatch"

    def test_mismatch_broken_symlink_in_normal_mode(self, tmp_path: Path) -> None:
        """Broken symlink in normal mode should be detected as mismatch."""
        # Create symlink to non-existent target (broken symlink)
        target_file = tmp_path / "test.txt"
        target_file.symlink_to(tmp_path / "nonexistent.txt")

        file_info = FileInfo(category=FileCategory.SYNC_ALWAYS, destination=None)

        # Normal mode expects regular file, but we have a broken symlink
        result = _is_file_type_mismatch(target_file, use_symlinks=False, file_info=file_info)

        assert result is True, "Broken symlink should be detected as mismatch in normal mode"


# =============================================================================
# Security tests for symlink target validation
# =============================================================================


class TestSymlinkSecurityValidation:
    """Security tests for symlink target validation.

    These tests verify that symlinks cannot be created to targets outside
    the source repository, preventing potential security issues where a
    malicious source could expose sensitive files from the target system.
    """

    def test_validate_source_path_rejects_path_outside_source_root(self, tmp_path: Path) -> None:
        """Symlink source outside source repository should be rejected."""
        source_root = tmp_path / "source_repo"
        source_root.mkdir()

        # Create a file outside the source repository
        outside_file = tmp_path / "outside_secrets.txt"
        outside_file.write_text("secret data")

        # Attempting to validate a path outside source_root should raise ValueError
        with pytest.raises(ValueError, match="Security violation"):
            validate_source_path(outside_file, source_root)

    def test_validate_source_path_accepts_path_inside_source_root(self, tmp_path: Path) -> None:
        """Symlink source inside source repository should be accepted."""
        source_root = tmp_path / "source_repo"
        source_root.mkdir()

        # Create a file inside the source repository
        inside_file = source_root / ".claude" / "scripts" / "sync.sh"
        inside_file.parent.mkdir(parents=True, exist_ok=True)
        inside_file.write_text("#!/bin/bash\necho hello")

        # This should not raise any exception
        validate_source_path(inside_file, source_root)

    def test_create_symlink_rejects_source_outside_repository(self, tmp_path: Path) -> None:
        """create_symlink_with_fallback should reject sources outside source_root."""
        source_root = tmp_path / "source_repo"
        source_root.mkdir()

        target_path = tmp_path / "target_repo"
        target_path.mkdir()

        # Create a file outside the source repository (e.g., /etc/passwd simulation)
        outside_file = tmp_path / "sensitive_file.txt"
        outside_file.write_text("sensitive data")

        target_symlink = target_path / ".claude" / "malicious_link"

        # Attempting to create a symlink to a file outside source_root should fail
        with pytest.raises(ValueError, match="Security violation"):
            create_symlink_with_fallback(
                source=outside_file,
                target=target_symlink,
                source_root=source_root,
                relative=True,
            )

    def test_create_symlink_rejects_path_traversal_attack(self, tmp_path: Path) -> None:
        """create_symlink_with_fallback should reject path traversal attempts."""
        source_root = tmp_path / "source_repo"
        source_root.mkdir()

        target_path = tmp_path / "target_repo"
        target_path.mkdir()

        # Create a file outside via path traversal
        outside_file = tmp_path / "outside.txt"
        outside_file.write_text("outside content")

        target_symlink = target_path / ".claude" / "traversal_link"

        # Even if we construct a path that looks like it's inside but resolves outside
        # This tests the resolve() check in validate_source_path
        with pytest.raises(ValueError, match="Security violation"):
            create_symlink_with_fallback(
                source=outside_file,
                target=target_symlink,
                source_root=source_root,
                relative=True,
            )


# Run all tests
if __name__ == "__main__":
    # Check git availability before running tests
    if not check_git_available():
        print("ERROR: Git is not available. These tests require git to be installed.")
        print("Please install git and ensure it's in your PATH.")
        sys.exit(1)

    test_validate_gitignore_compliance_clean_repo()
    test_validate_gitignore_compliance_with_tracked_files()
    test_validate_gitignore_compliance_directory_pattern()
    test_validate_gitignore_compliance_no_git_repo()
    test_update_gitignore_adds_missing_entries()
    test_update_gitignore_preserves_existing_entries()
    test_update_gitignore_creates_file_if_missing()
    test_update_gitignore_returns_correct_additions()
    test_enable_shadow_mode_creates_exclude_entry()
    test_enable_shadow_mode_creates_marker_file()
    test_enable_shadow_mode_detects_tracked_files()
    test_is_shadow_mode_enabled_returns_true_when_marker_exists()
    test_is_shadow_mode_enabled_returns_false_when_marker_missing()
    test_enable_shadow_mode_skips_existing_gitignore_entries()
    test_should_skip_sync_tool_internals()
    test_nested_gitignore_files_prevent_tracking()
    test_nested_gitignore_handles_subdirectories()

    print("\n✅ All tests passed!")
