"""Core file organization logic.

This module contains the FileOrganizer class which handles scanning, categorizing,
planning, and executing file organization operations. It supports organizing by
file type, modification date, or file size.
"""

from __future__ import annotations

import contextlib
import fnmatch
import os
import time
from pathlib import Path

from .config import Config, load_config
from .models import (
    EXTENSION_CATEGORIES,
    FileCategory,
    OrganizeMode,
    OrganizePlan,
    UndoLog,
)


def get_undo_log_path() -> Path:
    """Get the undo log file path.

    Uses a local import to allow patching in tests.
    """
    from .config import get_undo_log_path as _get_undo_log_path

    return _get_undo_log_path()


class FileOrganizer:
    """Handles file organization operations.

    This class encapsulates all logic for organizing files in a directory.
    It reads configuration, filters files based on ignore patterns, categorizes
    files, creates an organization plan, and executes the plan by moving files.

    Attributes:
        config: Configuration object containing organize modes, ignore patterns,
            safety settings, and advanced options.
    """

    def __init__(self, config: Config | None = None):
        """Initialize the FileOrganizer.

        Args:
            config: Optional Config object. If not provided, loads default config.
        """
        self.config = config or Config(load_config())

    def should_ignore(self, path: Path) -> bool:
        """Check if a file should be ignored based on configuration.

        Files are ignored if they match any of:
        - Hidden files (starting with '.') when hidden_files is True
        - System files (Thumbs.db, desktop.ini, .ds_store) when system_files is True
        - Custom glob patterns from the ignore.patterns config

        Args:
            path: Path to the file to check.

        Returns:
            True if the file should be ignored, False otherwise.
        """
        ignore_cfg = self.config._data["ignore"]

        # Hidden files (Unix-style dotfiles)
        if ignore_cfg["hidden_files"] and path.name.startswith("."):
            return True

        # System files (Windows and macOS)
        if ignore_cfg["system_files"] and path.name.lower() in {
            "thumbs.db",
            "desktop.ini",
            ".ds_store",
        }:
            return True

        # Custom patterns from config (e.g., "*.tmp", "*.crdownload")
        return any(fnmatch.fnmatch(path.name, pattern) for pattern in ignore_cfg["patterns"])

    def get_category(self, path: Path) -> FileCategory:
        """Get the category for a file based on its extension.

        Args:
            path: Path to the file to categorize.

        Returns:
            FileCategory enum value (e.g., IMAGES, DOCUMENTS, OTHER).
        """
        suffix = path.suffix.lower()
        return EXTENSION_CATEGORIES.get(suffix, FileCategory.OTHER)

    def get_date_folder(self, path: Path, date_format: str) -> str:
        """Get date-based folder name from file's modification time.

        Args:
            path: Path to the file.
            date_format: strftime format string (e.g., "%Y-%m" for "2024-01").

        Returns:
            Formatted date string for use as folder name.
        """
        mtime = path.stat().st_mtime
        return time.strftime(date_format, time.localtime(mtime))

    def get_size_folder(self, path: Path, thresholds: dict[str, int]) -> str:
        """Get size-based folder name based on configurable thresholds.

        Args:
            path: Path to the file.
            thresholds: Dict with 'small_mb' and 'large_mb' keys (in MB).

        Returns:
            Folder name: "Small", "Medium", or "Large".
        """
        size_mb = path.stat().st_size / (1024 * 1024)
        if size_mb < thresholds["small_mb"]:
            return "Small"
        elif size_mb > thresholds["large_mb"]:
            return "Large"
        return "Medium"

    def plan_organize(
        self,
        source_dir: Path,
        mode: OrganizeMode,
        dry_run: bool = True,
    ) -> OrganizePlan:
        """Create an organization plan for a directory.

        Scans the source directory, filters ignored files, determines destination
        folders based on the organization mode, and handles name conflicts.

        Args:
            source_dir: Directory to organize.
            mode: Organization mode (TYPE, DATE, or SIZE).
            dry_run: If True, creates plan without executing (default: True).

        Returns:
            OrganizePlan containing all planned file moves.

        Raises:
            ValueError: If source directory does not exist.
        """
        plan = OrganizePlan(
            mode=mode,
            source_dir=source_dir.resolve(),
            dry_run=dry_run,
        )

        if not source_dir.exists():
            raise ValueError(f"Source directory does not exist: {source_dir}")

        # Load organize-specific config
        organize_cfg = self.config._data["organize"]
        date_format = organize_cfg["date_format"]
        size_thresholds = organize_cfg["size_thresholds"]
        create_subdirs = organize_cfg["create_subdirs"]

        for file_path in source_dir.iterdir():
            # Skip directories, only process files
            if not file_path.is_file():
                continue

            # Skip ignored files
            if self.should_ignore(file_path):
                continue

            # Check max file size limit from safety config
            max_size = self.config._data["safety"]["max_file_size_mb"] * 1024 * 1024
            if file_path.stat().st_size > max_size:
                continue

            # Determine destination folder based on mode
            if mode == OrganizeMode.TYPE:
                category = self.get_category(file_path)
                folder_name = category.value
            elif mode == OrganizeMode.DATE:
                folder_name = self.get_date_folder(file_path, date_format)
            elif mode == OrganizeMode.SIZE:
                folder_name = self.get_size_folder(file_path, size_thresholds)
            else:
                folder_name = "Other"

            # Destination directory: category subdir or flat in source
            dest_dir = source_dir / folder_name if create_subdirs else source_dir

            dest_path = dest_dir / file_path.name

            # Handle name conflicts by appending _1, _2, etc.
            counter = 1
            original_dest = dest_path
            while dest_path.exists():
                stem = original_dest.stem
                suffix = original_dest.suffix
                dest_path = dest_dir / f"{stem}_{counter}{suffix}"
                counter += 1

            plan.add_action(file_path, dest_path, file_path.stat().st_size)

        return plan

    def execute_plan(self, plan: OrganizePlan) -> UndoLog:
        """Execute an organization plan, moving files to their destinations.

        Uses os.replace for atomic moves (works reliably on Windows).
        Logs each action for potential undo.

        Args:
            plan: OrganizePlan containing actions to execute.

        Returns:
            UndoLog with results (success/error info) for undo capability.
        """
        # Respect dry_run flag - if dry run, just return empty log
        if plan.dry_run:
            undo_log = UndoLog(plan=plan)
            undo_log.completed_at = time.time()
            return undo_log

        undo_log = UndoLog(plan=plan)

        for action in plan.actions:
            try:
                # Use resolved absolute paths for reliability on Windows
                src = action.source.resolve()
                dst = action.destination.resolve()

                # Verify source file exists
                if not src.exists():
                    raise FileNotFoundError(f"Source file not found: {src}")

                # Create destination directory if needed
                dst.parent.mkdir(parents=True, exist_ok=True)

                # Move file using os.replace (atomic on same filesystem, works on Windows)
                os.replace(src, dst)

                # Preserve timestamps if configured
                if self.config._data["safety"]["preserve_timestamps"]:
                    # Note: source no longer exists, so we'd need to store timestamps beforehand
                    # This is a simplified version
                    pass

            except Exception as e:
                undo_log.success = False
                undo_log.error = str(e)
                # Continue with other files if ignore_errors is set
                if not self.config._data["advanced"]["ignore_errors"]:
                    break

        undo_log.completed_at = time.time()
        self._save_undo_log(undo_log)
        return undo_log

    def _save_undo_log(self, undo_log: UndoLog) -> None:
        """Save undo log to JSON file for later reversal.

        Args:
            undo_log: UndoLog to persist.
        """
        log_path = get_undo_log_path()
        log_path.parent.mkdir(parents=True, exist_ok=True)
        with log_path.open("w", encoding="utf-8") as f:
            f.write(undo_log.to_json())

    def undo_last(self) -> UndoLog | None:
        """Undo the last organization operation by moving files back.

        Reads the undo log, reverses each action in reverse order,
        removes the undo log on success, and cleans up empty directories.

        Returns:
            UndoLog with success status, or None if no log exists.
        """
        log_path = get_undo_log_path()
        if not log_path.exists():
            return None

        try:
            with log_path.open("r", encoding="utf-8") as f:
                undo_log = UndoLog.from_json(f.read())
        except Exception:
            return None

        # Check if already undone (log file would be removed)
        if not undo_log.success:
            return None

        # Reverse the actions in reverse order
        for action in reversed(undo_log.plan.actions):
            try:
                # Use resolved absolute paths
                src = action.source.resolve()
                dst = action.destination.resolve()

                if dst.exists():
                    src.parent.mkdir(parents=True, exist_ok=True)
                    os.replace(dst, src)
            except Exception as e:
                undo_log.success = False
                undo_log.error = f"Undo failed: {e}"
                break

        # Remove undo log on successful undo
        if undo_log.success:
            log_path.unlink(missing_ok=True)
            # Clean up empty directories created during organize
            self._cleanup_empty_dirs(undo_log.plan.source_dir)

        return undo_log

    def _cleanup_empty_dirs(self, root: Path) -> None:
        """Remove empty directories recursively from root.

        Iterates from deepest to shallowest to ensure parent dirs
        are removed after children.

        Args:
            root: Root directory to clean (won't remove root itself).
        """
        for dirpath in sorted(root.rglob("*"), key=lambda p: len(p.parts), reverse=True):
            if dirpath.is_dir() and dirpath != root:
                with contextlib.suppress(OSError):
                    dirpath.rmdir()  # Only removes if empty

    def get_last_undo_log(self) -> UndoLog | None:
        """Get the last undo log without executing undo.

        Useful for previewing what would be undone.

        Returns:
            UndoLog if exists and valid, None otherwise.
        """
        log_path = get_undo_log_path()
        if not log_path.exists():
            return None
        try:
            with log_path.open("r", encoding="utf-8") as f:
                return UndoLog.from_json(f.read())
        except Exception:
            return None


def organize_directory(
    source_dir: Path,
    mode: OrganizeMode,
    dry_run: bool = True,
    config: Config | None = None,
) -> tuple[OrganizePlan, UndoLog | None]:
    """Convenience function to organize a directory.

    Creates a FileOrganizer, plans the organization, and optionally executes it.

    Args:
        source_dir: Directory to organize.
        mode: Organization mode (TYPE, DATE, or SIZE).
        dry_run: If True, only plan without moving files (default: True).
        config: Optional Config object.

    Returns:
        Tuple of (OrganizePlan, UndoLog or None if dry_run).
    """
    organizer = FileOrganizer(config)
    plan = organizer.plan_organize(source_dir, mode, dry_run)

    if dry_run:
        return plan, None

    undo_log = organizer.execute_plan(plan)
    return plan, undo_log
