"""
File operations for syncing with permission preservation.
"""

import os
import shutil
from dataclasses import dataclass
from pathlib import Path

from persuade.categorization import (
    FileInfo,
    categorize_file,
    get_default_destination,
    should_skip,
    validate_destination_path,
)
from persuade.constants import EXECUTABLE_PATTERNS, REQUIRED_DIRECTORIES, FileCategory
from persuade.git_ops import is_git_ignored
from persuade.logging import get_logger
from persuade.symlink_ops import create_symlink_with_fallback, should_use_symlink_for_file

# Re-export categorization items for backwards compatibility
__all__ = [
    "FileInfo",
    "categorize_file",
    "validate_destination_path",
    "FileChange",
    "ChangeSet",
    "scan_source_directory",
    "compute_changes",
    "apply_file_changes",
]

logger = get_logger()


@dataclass
class FileChange:
    """Represents a file change."""

    path: str
    category: str  # 'added', 'modified', 'removed'
    file_type: FileCategory


@dataclass
class ChangeSet:
    """Collection of file changes."""

    added: list[str]
    modified: list[str]
    removed: list[str]

    def has_changes(self) -> bool:
        """Check if there are any changes."""
        return bool(self.added or self.modified or self.removed)


def scan_source_directory(
    source_path: Path, exclude_patterns: list[str] | None = None
) -> dict[str, FileInfo]:
    """
    Scan source repository for files matching sync patterns.

    This function scans from the repository root (not just .claude/), enabling
    sync of files from anywhere in the repo (e.g., .github/workflows/).

    Two-layer skip strategy (optimized for performance):
    1. Explicit SKIP_PATTERNS: Skip tracked files/dirs that shouldn't sync
       (.git/*, README.md, etc.) - Fast: Pattern matching only, no external calls
    2. Git-aware skipping: Automatically skip gitignored files/dirs
       (.pytest_cache/, __pycache__/, etc.) - Slower: Requires git check-ignore
       call, but skips entire directories

    Args:
        source_path: Path to source repository root
        exclude_patterns: Additional patterns to exclude

    Returns:
        Dictionary mapping root-relative paths to their FileInfo
    """
    if not source_path.exists():
        raise ValueError(f"Source repository not found: {source_path}")

    files: dict[str, FileInfo] = {}

    def scan_recursively(current_path: Path) -> None:
        """Recursively scan directory, skipping gitignored dirs."""
        try:
            for entry in current_path.iterdir():
                rel_path = str(entry.relative_to(source_path))

                # Skip if matches explicit patterns (fast check first)
                if should_skip(entry, source_path, exclude_patterns):
                    logger.debug("skipping_explicit_pattern", path=rel_path)
                    continue

                # Skip if gitignored (check directories to avoid descending into them)
                if is_git_ignored(entry, source_path):
                    logger.debug("skipping_gitignored", path=rel_path)
                    continue

                if entry.is_dir():
                    # Recursively scan subdirectory
                    scan_recursively(entry)
                elif entry.is_file():
                    # Categorize file using root-relative path
                    file_info = categorize_file(rel_path)

                    # Skip files marked as SKIP_ALWAYS
                    if file_info.category != FileCategory.SKIP_ALWAYS:
                        files[rel_path] = file_info
                    else:
                        logger.debug("skipping_category_always", path=rel_path)

        except PermissionError:
            logger.debug("permission_denied", path=str(current_path))

    scan_recursively(source_path)
    return files


def compute_changes(
    source_path: Path,
    target_path: Path,
    source_files: dict[str, FileInfo],
    strict: bool = False,
    use_symlinks: bool = False,
    force_update: bool = False,
) -> ChangeSet:
    """
    Compute what files would change.

    Args:
        source_path: Source repository path (root)
        target_path: Target project path (root)
        source_files: Files from source with their FileInfo (paths are root-relative)
        strict: If True, may include files to remove
        use_symlinks: If True, detect mode transitions (symlink vs copy mismatch).
            When enabled, files with incorrect type (symlink vs copy) are marked
            as modified and will be replaced during sync.
        force_update: If True, treat COPY_IF_MISSING files as SYNC_ALWAYS
            (force update even if they exist in target).

    Returns:
        ChangeSet with lists of changes
    """
    added: list[str] = []
    modified: list[str] = []
    removed: list[str] = []

    for rel_path, file_info in source_files.items():
        # rel_path is now root-relative (e.g., ".claude/scripts/foo.py")
        source_file = source_path / rel_path

        # Determine target file path - use destination if specified, else use default
        if file_info.destination:
            target_file = target_path / file_info.destination
        else:
            # Use default destination (same path for .claude/ files)
            default_dest = get_default_destination(rel_path)
            target_file = target_path / default_dest

        if not target_file.exists():
            # File doesn't exist in target
            should_add = file_info.category in (
                FileCategory.COPY_IF_MISSING,
                FileCategory.SYNC_ALWAYS,
                FileCategory.MERGE_SMART,
            )
            if should_add:
                added.append(rel_path)
        else:
            # File exists in target
            if file_info.category == FileCategory.SYNC_ALWAYS:
                # Check for file type mismatch (mode transition) or content difference
                if _is_file_type_mismatch(
                    target_file, use_symlinks, file_info
                ) or not files_identical(source_file, target_file):
                    modified.append(rel_path)
            elif file_info.category == FileCategory.MERGE_SMART:
                # Settings.json - will be merged, count as modified
                modified.append(rel_path)
            elif (
                file_info.category == FileCategory.COPY_IF_MISSING
                and force_update
                and not files_identical(source_file, target_file)
            ):
                # Force update mode: treat COPY_IF_MISSING as SYNC_ALWAYS
                modified.append(rel_path)
            # COPY_IF_MISSING without force_update: don't modify existing files

    return ChangeSet(added=added, modified=modified, removed=removed)


def files_identical(file1: Path, file2: Path) -> bool:
    """
    Check if two files have identical content.

    Args:
        file1: First file path
        file2: Second file path

    Returns:
        True if files are identical
    """
    if not file1.exists() or not file2.exists():
        return False

    # Quick size check
    if file1.stat().st_size != file2.stat().st_size:
        return False

    # Byte-by-byte comparison
    with open(file1, "rb") as f1, open(file2, "rb") as f2:
        return f1.read() == f2.read()


def _is_file_type_mismatch(
    target_file: Path,
    use_symlinks: bool,
    file_info: FileInfo,
) -> bool:
    """
    Check if target file is wrong type for current sync mode.

    This enables mode transitions: when switching between normal and editable modes,
    files that exist but are the wrong type (symlink vs regular file) need to be
    replaced.

    Args:
        target_file: Path to target file
        use_symlinks: True if editable mode (expect symlinks for eligible files)
        file_info: File categorization info

    Returns:
        True if file exists but is wrong type for current mode
    """
    # Treat only paths that are neither existing files nor symlinks as "no target".
    # Note: exists() returns False for broken symlinks, but is_symlink() is True,
    # so broken symlinks intentionally skip this early return and are handled below
    # by the type-mismatch logic.
    if not target_file.exists() and not target_file.is_symlink():
        return False  # No target at all (not even a symlink path), not a mismatch

    # Only SYNC_ALWAYS files can be symlinked
    # (settings.json, COPY_IF_MISSING files are always copied)
    if not file_info.category.can_symlink():
        return False  # This file type is always copied, no mismatch possible

    is_symlink = target_file.is_symlink()

    if use_symlinks and not is_symlink:
        # Editable mode: expect symlink, found regular file
        # Need to replace copy with symlink
        logger.debug(
            "mode_mismatch_detected",
            path=str(target_file),
            expected="symlink",
            found="regular",
        )
        return True
    elif not use_symlinks and is_symlink:
        # Normal mode: expect regular file, found symlink
        # Need to replace symlink with copy
        logger.debug(
            "mode_mismatch_detected",
            path=str(target_file),
            expected="regular",
            found="symlink",
        )
        return True

    return False


def copy_file_with_permissions(
    source: Path,
    target: Path,
    source_root: Path,
    use_symlink: bool = False,
    file_info: FileInfo | None = None,
) -> None:
    """
    Copy file preserving permissions and metadata, or create symlink.

    Args:
        source: Source file path
        target: Target file path
        source_root: Root of source repository (required for security validation in editable mode)
        use_symlink: If True, create symlink instead of copying
        file_info: File information (used to determine if symlinking is allowed)
    """
    # Determine if we should create symlink or copy
    should_symlink = should_use_symlink_for_file(use_symlink, file_info, target)

    if should_symlink:
        # Create symlink (relative by default for portability)
        create_symlink_with_fallback(source, target, source_root, relative=True)
    else:
        # Create parent directory if needed
        target.parent.mkdir(parents=True, exist_ok=True)

        # Remove existing symlinks before copying. This handles:
        # 1. Broken symlinks: exists() returns False, but shutil.copy2 would fail
        # 2. Mode transitions: when switching from editable (symlink) to normal (copy),
        #    the symlink points to source, and shutil.copy2 raises SameFileError
        if target.is_symlink():
            target.unlink()

        # Copy file with metadata (includes permissions)
        shutil.copy2(source, target)

        # Log the copy operation
        logger.debug(
            "file_copied",
            source=str(source),
            target=str(target),
            category=file_info.category.value if file_info else "unknown",
        )

        # Ensure executable bit is preserved for scripts
        if source.stat().st_mode & 0o111:  # Has any execute bit
            current_mode = target.stat().st_mode
            target.chmod(current_mode | 0o111)  # Add execute for all


def ensure_directories(target_path: Path) -> None:
    """
    Ensure required .claude/ subdirectories exist.

    Args:
        target_path: Target project path
    """
    claude_dir = target_path / ".claude"

    for dir_name in REQUIRED_DIRECTORIES:
        dir_path = claude_dir / dir_name
        dir_path.mkdir(parents=True, exist_ok=True)


def apply_file_changes(
    source_path: Path,
    target_path: Path,
    changes: ChangeSet,
    source_files: dict[str, FileInfo],
    use_symlinks: bool = False,
) -> None:
    """
    Apply file changes to target.

    Args:
        source_path: Source repository path (root)
        target_path: Target project path (root)
        changes: Changes to apply
        source_files: Source files with FileInfo (paths are root-relative)
        use_symlinks: If True, create symlinks instead of copying (editable mode)
    """
    target_claude = target_path / ".claude"

    # Ensure base directory exists
    target_claude.mkdir(parents=True, exist_ok=True)

    # Create required directories
    ensure_directories(target_path)

    # Apply additions
    for rel_path in changes.added:
        # rel_path is now root-relative (e.g., ".claude/scripts/foo.py")
        source_file = source_path / rel_path
        file_info = source_files[rel_path]

        # Determine target path - use destination if specified
        if file_info.destination:
            # Validate destination path for security
            validate_destination_path(file_info.destination, target_path)
            target_file = target_path / file_info.destination
            logger.info(
                "syncing_with_destination",
                source=rel_path,
                destination=file_info.destination,
            )
        else:
            # Use default destination (same path for .claude/ files)
            default_dest = get_default_destination(rel_path)
            target_file = target_path / default_dest

        copy_file_with_permissions(
            source_file, target_file, source_path, use_symlink=use_symlinks, file_info=file_info
        )

    # Apply modifications (except settings.json which is handled separately)
    for rel_path in changes.modified:
        if rel_path == ".claude/settings.json":  # Now root-relative path
            continue  # Handled by settings merger

        source_file = source_path / rel_path
        file_info = source_files[rel_path]

        # Determine target path - use destination if specified
        if file_info.destination:
            # Validate destination path for security
            validate_destination_path(file_info.destination, target_path)
            target_file = target_path / file_info.destination
            logger.info(
                "syncing_with_destination",
                source=rel_path,
                destination=file_info.destination,
            )
        else:
            # Use default destination (same path for .claude/ files)
            default_dest = get_default_destination(rel_path)
            target_file = target_path / default_dest

        copy_file_with_permissions(
            source_file, target_file, source_path, use_symlink=use_symlinks, file_info=file_info
        )


def _is_documentation_file(file_path: Path) -> bool:
    """Check if a file is documentation (should not be executable).

    Case-insensitive check that identifies documentation files:
    1. ALL Markdown files (*.md) - these should never be executable
    2. README files without .md extension (readme, readme.txt, readme.rst)

    The asymmetry is intentional: we want ALL .md files treated as documentation
    (including CHANGELOG.md, CONTRIBUTING.md, etc.), while only specific README
    variants are checked for non-.md extensions to avoid matching files like
    "readme-script.sh".
    """
    name = file_path.name.lower()
    readme_names = {
        "readme",
        "readme.md",
        "readme.txt",
        "readme.rst",
        "readme.markdown",
        "readme.mdown",
    }
    return name.endswith(".md") or name in readme_names


def validate_executable_permissions(target_path: Path) -> list[str]:
    """
    Check that scripts have executable permissions.

    Args:
        target_path: Target project path (root)

    Returns:
        List of files missing executable permissions
    """
    missing = []

    # EXECUTABLE_PATTERNS are now root-relative (e.g., ".claude/hooks/*.sh")
    for pattern in EXECUTABLE_PATTERNS:
        for file_path in target_path.glob(pattern):
            # Skip documentation files (README, *.md)
            if _is_documentation_file(file_path):
                continue
            if file_path.is_file() and not os.access(file_path, os.X_OK):
                missing.append(str(file_path.relative_to(target_path)))

    return missing


def fix_executable_permissions(target_path: Path) -> list[str]:
    """
    Fix executable permissions on scripts.

    Args:
        target_path: Target project path (root)

    Returns:
        List of files that were fixed
    """
    fixed = []

    # EXECUTABLE_PATTERNS are now root-relative (e.g., ".claude/hooks/*.sh")
    for pattern in EXECUTABLE_PATTERNS:
        for file_path in target_path.glob(pattern):
            # Skip documentation files (README, *.md)
            if _is_documentation_file(file_path):
                continue
            if file_path.is_file() and not os.access(file_path, os.X_OK):
                # Add execute permission for user, group, other
                current_mode = file_path.stat().st_mode
                file_path.chmod(current_mode | 0o111)
                fixed.append(str(file_path.relative_to(target_path)))

    return fixed
