"""
Unit tests for destination path mapping in sync engine.
"""

import copy
import sys
import tempfile
from pathlib import Path

# Check if pytest is available for optional enhanced testing
try:
    import pytest  # noqa: F401

    PYTEST_AVAILABLE = True
except ImportError:
    PYTEST_AVAILABLE = False

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

from persuade import constants
from persuade.constants import FileCategory
from persuade.file_ops import (
    ChangeSet,
    FileInfo,
    apply_file_changes,
    categorize_file,
    compute_changes,
    scan_source_directory,
    validate_destination_path,
)


def test_categorize_file_with_simple_rule() -> None:
    """Test categorize_file with simple FileCategory rule (backwards compat)."""
    # Test a simple rule without destination (now uses root-relative paths)
    file_info = categorize_file(".claude/commands/test.md")

    assert file_info.category == FileCategory.SYNC_ALWAYS
    assert file_info.destination is None
    print("✓ test_categorize_file_with_simple_rule passed")


def test_categorize_file_with_dict_rule() -> None:
    """Test categorize_file with dict-based rule containing destination."""
    # Temporarily modify FILE_RULES to test
    original_rules = copy.deepcopy(constants.FILE_RULES)

    try:
        # Add a test rule with destination template
        constants.FILE_RULES["test-hooks/*"] = {
            "category": FileCategory.SYNC_ALWAYS,
            "destination": ".git/hooks/{filename}",
        }

        file_info = categorize_file("test-hooks/pre-push")

        assert file_info.category == FileCategory.SYNC_ALWAYS
        assert file_info.destination == ".git/hooks/pre-push"
        print("✓ test_categorize_file_with_dict_rule passed")
    finally:
        # Restore original rules
        constants.FILE_RULES.clear()
        constants.FILE_RULES.update(original_rules)


def test_categorize_file_with_filename_placeholder() -> None:
    """Test categorize_file with {filename} placeholder.

    External files use {filename} to preserve original filenames.
    """
    original_rules = copy.deepcopy(constants.FILE_RULES)

    try:
        constants.FILE_RULES["test-workflows/*.yml"] = {
            "category": FileCategory.SYNC_ALWAYS,
            "destination": ".github/workflows/{filename}",
        }

        file_info = categorize_file("test-workflows/ci.yml")

        assert file_info.category == FileCategory.SYNC_ALWAYS
        assert file_info.destination == ".github/workflows/ci.yml"

        file_info2 = categorize_file("test-workflows/release.yml")
        assert file_info2.destination == ".github/workflows/release.yml"

        print("✓ test_categorize_file_with_filename_placeholder passed")
    finally:
        constants.FILE_RULES.clear()
        constants.FILE_RULES.update(original_rules)


def test_categorize_file_with_exclude() -> None:
    """Test categorize_file with exclude patterns."""
    original_rules = copy.deepcopy(constants.FILE_RULES)

    try:
        # Add a rule with exclude pattern
        constants.FILE_RULES["test-hooks/*"] = {
            "category": FileCategory.SYNC_ALWAYS,
            "destination": ".git/hooks/{filename}",
            "exclude": ["*.md", "README*"],
        }

        # Regular hook file should match
        file_info = categorize_file("test-hooks/pre-push")
        assert file_info.category == FileCategory.SYNC_ALWAYS
        assert file_info.destination == ".git/hooks/pre-push"

        # README should be excluded and fall through to default (which is now SKIP_ALWAYS)
        file_info = categorize_file("test-hooks/README.md")
        assert file_info.category == FileCategory.SKIP_ALWAYS  # Explicit whitelist approach
        assert file_info.destination is None  # No custom destination

        print("✓ test_categorize_file_with_exclude passed")
    finally:
        constants.FILE_RULES.clear()
        constants.FILE_RULES.update(original_rules)


def test_validate_destination_path_valid() -> None:
    """Test validation of valid destination paths."""
    with tempfile.TemporaryDirectory() as tmpdir:
        project_root = Path(tmpdir)

        # Valid paths within project
        assert validate_destination_path(".git/hooks/pre-push", project_root)
        assert validate_destination_path(".github/workflows/test.yml", project_root)
        assert validate_destination_path(".vscode/settings.json", project_root)
        assert validate_destination_path("config/app.conf", project_root)

        print("✓ test_validate_destination_path_valid passed")


def test_validate_destination_path_traversal() -> None:
    """Test that path traversal attempts are rejected."""
    with tempfile.TemporaryDirectory() as tmpdir:
        project_root = Path(tmpdir)

        # Attempt to escape project root - should raise ValueError
        with pytest.raises(ValueError, match=r"(escapes project root|\.\.)"):
            validate_destination_path("../../etc/passwd", project_root)

        with pytest.raises(ValueError, match=r"(escapes project root|\.\.)"):
            validate_destination_path("../outside/file.txt", project_root)

        print("✓ test_validate_destination_path_traversal passed")


def test_validate_destination_path_symlink_escape() -> None:
    """Test that symlink-based escape attempts are rejected."""
    with tempfile.TemporaryDirectory() as tmpdir:
        project_root = Path(tmpdir)

        # Create a symlink pointing outside project
        outside_dir = Path(tmpdir) / "outside"
        outside_dir.mkdir()

        symlink_path = project_root / "symlink"
        # Note: On some systems this might fail, so we handle it gracefully
        try:
            symlink_path.symlink_to(outside_dir)

            # Try to use the symlink
            try:
                validate_destination_path("symlink/evil.txt", project_root)
                # Symlink points to sibling dir, which is outside project_root
                # This should be caught by the resolve() check
                print("  Note: Symlink validation needs to check resolved path")
            except ValueError:
                pass  # Expected
        except OSError:
            # Symlink creation failed (permission issue or OS doesn't support it)
            print("  Skipped symlink test (OS limitation)")

        print("✓ test_validate_destination_path_symlink_escape passed")


def test_validate_destination_path_git_hooks_worktree() -> None:
    """Test that .git/hooks/ paths are valid even when .git is a worktree file.

    In git worktrees, .git is a file (not a directory) containing a gitdir reference
    to the main repo's .git directory. When resolved, paths inside .git/ would point
    outside the worktree's project root. This is expected git behavior, not a security
    issue, so we handle it specially.
    """
    with tempfile.TemporaryDirectory() as tmpdir:
        project_root = Path(tmpdir)

        # Simulate a git worktree: .git is a file, not a directory
        git_file = project_root / ".git"
        git_file.write_text("gitdir: /some/other/path/.git/worktrees/myworktree")

        # .git/hooks/pre-push should still be valid
        assert validate_destination_path(".git/hooks/pre-push", project_root)
        assert validate_destination_path(".git/hooks/pre-commit", project_root)
        assert validate_destination_path(".git/hooks/commit-msg", project_root)

        print("✓ test_validate_destination_path_git_hooks_worktree passed")


def test_validate_destination_path_git_hooks_invalid_names() -> None:
    """Test that git hook names are validated for safety."""
    with tempfile.TemporaryDirectory() as tmpdir:
        project_root = Path(tmpdir)

        # Valid hook names
        assert validate_destination_path(".git/hooks/pre-push", project_root)
        assert validate_destination_path(".git/hooks/pre-commit.sample", project_root)
        assert validate_destination_path(".git/hooks/my-custom-hook", project_root)

        # Invalid hook names (shell metacharacters, etc.)
        with pytest.raises(ValueError, match="invalid characters"):
            validate_destination_path(".git/hooks/hook;evil", project_root)

        with pytest.raises(ValueError, match="invalid characters"):
            validate_destination_path(".git/hooks/hook`whoami`", project_root)

        with pytest.raises(ValueError, match="invalid characters"):
            validate_destination_path(".git/hooks/hook$(cmd)", project_root)

        print("✓ test_validate_destination_path_git_hooks_invalid_names passed")


def test_validate_destination_path_git_hooks_empty_name() -> None:
    """Test that empty git hook names are rejected."""
    with tempfile.TemporaryDirectory() as tmpdir:
        project_root = Path(tmpdir)

        # Empty hook name (trailing slash)
        with pytest.raises(ValueError, match="cannot be empty"):
            validate_destination_path(".git/hooks/", project_root)

        # Just .git/hooks without trailing slash also has no hook name
        with pytest.raises(ValueError, match="cannot be empty"):
            validate_destination_path(".git/hooks", project_root)

        print("✓ test_validate_destination_path_git_hooks_empty_name passed")


def test_validate_destination_path_git_hooks_dot_names() -> None:
    """Test that '.' and '..' are rejected as path components."""
    with tempfile.TemporaryDirectory() as tmpdir:
        project_root = Path(tmpdir)

        # "." as final component gets normalized away, resulting in empty hook name
        with pytest.raises(ValueError, match=r"cannot be empty"):
            validate_destination_path(".git/hooks/.", project_root)

        # ".." as path component is caught by the earlier ".." check
        with pytest.raises(ValueError, match=r"contains '\.\.'"):
            validate_destination_path(".git/hooks/..", project_root)

        # "." components get normalized away, so these paths are valid
        # (they effectively become .git/hooks/pre-push and .git/hooks/subdir)
        assert validate_destination_path(".git/hooks/./pre-push", project_root)
        assert validate_destination_path(".git/hooks/subdir/.", project_root)

        print("✓ test_validate_destination_path_git_hooks_dot_names passed")


def test_validate_destination_path_git_hooks_consecutive_dots() -> None:
    """Test that consecutive dots within a component are rejected."""
    with tempfile.TemporaryDirectory() as tmpdir:
        project_root = Path(tmpdir)

        # Consecutive dots in hook name
        with pytest.raises(ValueError, match=r"cannot contain consecutive dots"):
            validate_destination_path(".git/hooks/a..b", project_root)

        # Consecutive dots in subdirectory
        with pytest.raises(ValueError, match=r"cannot contain consecutive dots"):
            validate_destination_path(".git/hooks/foo..bar/pre-push", project_root)

        # Triple dots should also be caught
        with pytest.raises(ValueError, match=r"cannot contain consecutive dots"):
            validate_destination_path(".git/hooks/a...b", project_root)

        # Single dots are still allowed (e.g., file extensions)
        assert validate_destination_path(".git/hooks/pre-commit.sample", project_root)
        assert validate_destination_path(".git/hooks/.hidden", project_root)

        print("✓ test_validate_destination_path_git_hooks_consecutive_dots passed")


def test_validate_destination_path_git_hooks_normalized() -> None:
    """Test that .git/hooks paths are properly normalized before validation."""
    with tempfile.TemporaryDirectory() as tmpdir:
        project_root = Path(tmpdir)

        # Path with redundant "." segments should still work
        assert validate_destination_path(".git/./hooks/pre-push", project_root)
        assert validate_destination_path(".git/hooks/./pre-push", project_root)

        # But path traversal attempts should still be caught
        with pytest.raises(ValueError, match=r"'\.\.'"):
            validate_destination_path(".git/hooks/../config", project_root)

        print("✓ test_validate_destination_path_git_hooks_normalized passed")


def test_validate_destination_path_git_hooks_subdirectories() -> None:
    """Test that .git/hooks paths with subdirectories are handled correctly.

    Git doesn't use subdirectories under .git/hooks/ for standard hooks, but users
    might organize custom scripts this way. The validation validates ALL path
    components after .git/hooks for character safety, not just the final one.

    For example, .git/hooks/subdir/script validates both "subdir" and "script".
    """
    with tempfile.TemporaryDirectory() as tmpdir:
        project_root = Path(tmpdir)

        # Subdirectories under .git/hooks/ should be allowed when all components are valid
        assert validate_destination_path(".git/hooks/subdir/script", project_root)
        assert validate_destination_path(".git/hooks/custom/pre-push", project_root)
        assert validate_destination_path(".git/hooks/a/b/c/hook-name", project_root)

        # Invalid characters in the final component should be rejected
        with pytest.raises(ValueError, match="invalid characters"):
            validate_destination_path(".git/hooks/subdir/hook;evil", project_root)

        # Invalid characters in INTERMEDIATE components should ALSO be rejected
        # This is critical for defense-in-depth to prevent shell injection
        with pytest.raises(ValueError, match="invalid characters"):
            validate_destination_path(".git/hooks/foo;bar/pre-push", project_root)

        with pytest.raises(ValueError, match="invalid characters"):
            validate_destination_path(".git/hooks/dir`whoami`/hook", project_root)

        with pytest.raises(ValueError, match="invalid characters"):
            validate_destination_path(".git/hooks/a/b$(cmd)/c/hook", project_root)

        # Empty final component should be rejected
        with pytest.raises(ValueError, match="cannot be empty"):
            validate_destination_path(".git/hooks/subdir/", project_root)

        print("✓ test_validate_destination_path_git_hooks_subdirectories passed")


def test_scan_source_directory_with_destinations() -> None:
    """Test that scan_source_directory returns FileInfo objects."""
    with tempfile.TemporaryDirectory() as tmpdir:
        source_path = Path(tmpdir)
        claude_dir = source_path / ".claude"
        claude_dir.mkdir()

        # Create test files
        commands_dir = claude_dir / "commands"
        commands_dir.mkdir()
        (commands_dir / "test.md").write_text("# Test")

        scripts_dir = claude_dir / "scripts"
        scripts_dir.mkdir()
        (scripts_dir / "test.sh").write_text("#!/bin/bash\necho test")

        # Scan directory (now returns root-relative paths)
        files = scan_source_directory(source_path)

        # Check that we got FileInfo objects (with root-relative keys)
        assert ".claude/commands/test.md" in files
        assert isinstance(files[".claude/commands/test.md"], FileInfo)
        assert files[".claude/commands/test.md"].category == FileCategory.SYNC_ALWAYS
        assert files[".claude/commands/test.md"].destination is None

        assert ".claude/scripts/test.sh" in files
        assert isinstance(files[".claude/scripts/test.sh"], FileInfo)

        print("✓ test_scan_source_directory_with_destinations passed")


def test_compute_changes_with_destination() -> None:
    """Test compute_changes handles destination paths correctly."""
    with tempfile.TemporaryDirectory() as tmpdir:
        source_path = Path(tmpdir) / "source"
        target_path = Path(tmpdir) / "target"

        # Setup source
        source_claude = source_path / ".claude"
        source_claude.mkdir(parents=True)
        test_file = source_claude / "test.txt"
        test_file.write_text("test content")

        # Setup target (empty)
        target_path.mkdir()

        # Create source_files with destination (using root-relative path as key)
        source_files = {
            ".claude/test.txt": FileInfo(
                category=FileCategory.SYNC_ALWAYS,
                destination=".config/test.txt",  # Custom destination
            )
        }

        # Compute changes
        changes = compute_changes(source_path, target_path, source_files)

        # File should be in added since destination doesn't exist
        assert ".claude/test.txt" in changes.added

        print("✓ test_compute_changes_with_destination passed")


def test_compute_changes_destination_exists() -> None:
    """Test compute_changes when destination file already exists."""
    with tempfile.TemporaryDirectory() as tmpdir:
        source_path = Path(tmpdir) / "source"
        target_path = Path(tmpdir) / "target"

        # Setup source
        source_claude = source_path / ".claude"
        source_claude.mkdir(parents=True)
        test_file = source_claude / "test.txt"
        test_file.write_text("new content")

        # Setup target with existing destination file
        target_path.mkdir()
        dest_dir = target_path / ".config"
        dest_dir.mkdir()
        dest_file = dest_dir / "test.txt"
        dest_file.write_text("old content")

        # Create source_files with destination
        source_files = {
            ".claude/test.txt": FileInfo(
                category=FileCategory.SYNC_ALWAYS, destination=".config/test.txt"
            )
        }

        # Compute changes
        changes = compute_changes(source_path, target_path, source_files)

        # File should be in modified since destination exists and content differs
        assert ".claude/test.txt" in changes.modified

        print("✓ test_compute_changes_destination_exists passed")


def test_apply_file_changes_with_destination() -> None:
    """Test apply_file_changes copies to custom destination."""
    with tempfile.TemporaryDirectory() as tmpdir:
        source_path = Path(tmpdir) / "source"
        target_path = Path(tmpdir) / "target"

        # Setup source
        source_claude = source_path / ".claude"
        source_claude.mkdir(parents=True)
        test_file = source_claude / "test.txt"
        test_content = "test content"
        test_file.write_text(test_content)

        # Setup target
        target_path.mkdir()

        # Create changes and source_files
        changes = ChangeSet(added=[".claude/test.txt"], modified=[], removed=[])
        source_files = {
            ".claude/test.txt": FileInfo(
                category=FileCategory.SYNC_ALWAYS, destination=".config/app/test.txt"
            )
        }

        # Apply changes
        apply_file_changes(source_path, target_path, changes, source_files)

        # Check that file was copied to destination
        dest_file = target_path / ".config/app/test.txt"
        assert dest_file.exists(), f"Destination file not found: {dest_file}"
        assert dest_file.read_text() == test_content

        # Check that file was NOT copied to default .claude/ location
        default_file = target_path / ".claude/test.txt"
        assert not default_file.exists(), "File should not be in default location"

        print("✓ test_apply_file_changes_with_destination passed")


def test_apply_file_changes_without_destination() -> None:
    """Test apply_file_changes uses default path when no destination."""
    with tempfile.TemporaryDirectory() as tmpdir:
        source_path = Path(tmpdir) / "source"
        target_path = Path(tmpdir) / "target"

        # Setup source
        source_claude = source_path / ".claude"
        source_claude.mkdir(parents=True)
        test_file = source_claude / "test.txt"
        test_content = "test content"
        test_file.write_text(test_content)

        # Setup target
        target_path.mkdir()

        # Create changes and source_files WITHOUT destination
        changes = ChangeSet(added=[".claude/test.txt"], modified=[], removed=[])
        source_files = {
            ".claude/test.txt": FileInfo(
                category=FileCategory.SYNC_ALWAYS,
                destination=None,  # No custom destination
            )
        }

        # Apply changes
        apply_file_changes(source_path, target_path, changes, source_files)

        # Check that file was copied to default location
        default_file = target_path / ".claude/test.txt"
        assert default_file.exists(), "File should be in default .claude/ location"
        assert default_file.read_text() == test_content

        print("✓ test_apply_file_changes_without_destination passed")


def test_apply_file_changes_validates_destination() -> None:
    """Test that apply_file_changes validates destination paths."""
    with tempfile.TemporaryDirectory() as tmpdir:
        source_path = Path(tmpdir) / "source"
        target_path = Path(tmpdir) / "target"

        # Setup source
        source_claude = source_path / ".claude"
        source_claude.mkdir(parents=True)
        test_file = source_claude / "test.txt"
        test_file.write_text("test content")

        # Setup target
        target_path.mkdir()

        # Create changes with malicious destination
        changes = ChangeSet(added=[".claude/test.txt"], modified=[], removed=[])
        source_files = {
            ".claude/test.txt": FileInfo(
                category=FileCategory.SYNC_ALWAYS,
                destination="../../etc/passwd",  # Path traversal attempt
            )
        }

        # Apply changes should raise ValueError
        with pytest.raises(ValueError, match=r"(escapes project root|\.\.)"):
            apply_file_changes(source_path, target_path, changes, source_files)

        print("✓ test_apply_file_changes_validates_destination passed")


def test_backwards_compatibility() -> None:
    """Test that existing code without destinations still works."""
    with tempfile.TemporaryDirectory() as tmpdir:
        source_path = Path(tmpdir) / "source"
        target_path = Path(tmpdir) / "target"

        # Setup source with typical .claude structure
        source_claude = source_path / ".claude"
        source_claude.mkdir(parents=True)

        # Create various files
        (source_claude / "commands").mkdir()
        (source_claude / "commands/test.md").write_text("# Test")

        (source_claude / "scripts").mkdir()
        (source_claude / "scripts/test.sh").write_text("#!/bin/bash\necho test")

        # Setup target
        target_path.mkdir()

        # Scan and apply - should work without destinations
        source_files = scan_source_directory(source_path)
        changes = compute_changes(source_path, target_path, source_files)
        apply_file_changes(source_path, target_path, changes, source_files)

        # Check files were copied to default locations
        assert (target_path / ".claude/commands/test.md").exists()
        assert (target_path / ".claude/scripts/test.sh").exists()

        print("✓ test_backwards_compatibility passed")


# Run all tests
if __name__ == "__main__":
    test_categorize_file_with_simple_rule()
    test_categorize_file_with_dict_rule()
    test_categorize_file_with_filename_placeholder()
    test_categorize_file_with_exclude()
    test_validate_destination_path_valid()
    test_validate_destination_path_traversal()
    test_validate_destination_path_symlink_escape()
    test_validate_destination_path_git_hooks_worktree()
    test_validate_destination_path_git_hooks_invalid_names()
    test_validate_destination_path_git_hooks_empty_name()
    test_validate_destination_path_git_hooks_dot_names()
    test_validate_destination_path_git_hooks_normalized()
    test_validate_destination_path_git_hooks_subdirectories()
    test_scan_source_directory_with_destinations()
    test_compute_changes_with_destination()
    test_compute_changes_destination_exists()
    test_apply_file_changes_with_destination()
    test_apply_file_changes_without_destination()
    test_apply_file_changes_validates_destination()
    test_backwards_compatibility()

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