"""
Android Reloader App

Handles hot reload on Android devices:
- Receives app updates via TCP server
- Uses file hashing to detect changes
- Manages service restarts and compilation
"""

# Standard library imports
import hashlib
import importlib
import json
import os
import socket
import sys
import traceback
import zipfile
from fnmatch import fnmatch
from pathlib import Path
from uuid import uuid4

# Third-party imports
import trio
from kivy.app import App as KivyApp
from kivy.base import async_runTouchApp
from kivy.core.window import Window
from kivy.factory import Factory as F
from kivy.lang import Builder
from kivy.logger import Logger
from kivy.utils import platform

# Android-specific imports (optional dependency)
try:
    from jnius import autoclass  # type: ignore

    JNIUS_AVAILABLE = True
except ImportError:
    JNIUS_AVAILABLE = False

from kivy_reloader import tree_formatter

# Local imports
from . import __version__
from .base_app import BaseReloaderApp
from .config import config
from .tree_formatter import format_file_tree
from .utils import get_kv_files_paths

# Constants
CTRL_R_KEYCODE = 114
MODULE_RELOAD_PASSES = 2
DNS_SERVER_IP = '8.8.8.8'
DNS_SERVER_PORT = 80


class AndroidApp(BaseReloaderApp, KivyApp):
    """Android hot reload app with TCP server and service management"""

    # ==================== INITIALIZATION ====================

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        Logger.info(f'Reloader: Kivy Reloader v{__version__}')
        self._initialize_file_hashing()
        self._setup_android_environment()
        self._setup_key_bindings()

        # Serialize update application to avoid concurrent extract/delete races
        self._update_lock = trio.Lock()

    def _initialize_file_hashing(self):
        """Initialize file hash tracking for hot reload detection."""
        main_py_file_path = os.path.join(os.getcwd(), 'main.py')

        # Initialize main.py hash
        if os.path.exists(main_py_file_path):
            self.main_py_hash = self.get_hash_of_file(main_py_file_path)
        else:
            self.main_py_hash = None

        # Initialize KV file hashes for hot reload
        self.kv_files_hashes = {
            file_name: self.get_hash_of_file(file_name)
            for file_name in get_kv_files_paths()
        }

        # Initialize service file hashes for live reload
        self.service_files_hashes = {
            file_name: self.get_hash_of_file(file_name)
            for file_name in config.SERVICE_FILES
        }

        # Initialize full reload file hashes
        self.full_reload_file_hashes = {
            file_name: self.get_hash_of_file(file_name)
            for file_name in config.FULL_RELOAD_FILES
            if os.path.exists(file_name)
        }

    def _setup_android_environment(self):
        """Configure Android-specific settings and compilation."""
        self.recompile_main()

    def _setup_key_bindings(self):
        """Setup keyboard shortcuts for manual reload."""
        self.bind_key(CTRL_R_KEYCODE, self.restart_app_on_android)

    # ==================== APP LIFECYCLE ====================

    async def async_run(self, async_lib='trio'):
        """
        Run the Android app asynchronously with TCP server.

        Starts the TCP server for receiving hot reload updates from desktop,
        then runs the main Kivy application loop.
        """
        async with trio.open_nursery() as nursery:
            Logger.info('Reloader: Starting Async Kivy app')
            self.nursery = nursery
            self.initialize_server()
            self._run_prepare()
            await async_runTouchApp(async_lib=async_lib)
            self._stop()
            nursery.cancel_scope.cancel()

    def restart_app_on_android(self):
        """
        Restart the Android application using Android intents.

        Uses the jnius library to interact with Android APIs and restart
        the application cleanly by creating a new intent and clearing tasks.
        """
        Logger.info('Restarting the app on smartphone')

        if not JNIUS_AVAILABLE:
            Logger.error('jnius not available for Android restart')
            return

        try:
            # Get Android classes
            Intent = autoclass('android.content.Intent')
            PythonActivity = autoclass('org.kivy.android.PythonActivity')
            System = autoclass('java.lang.System')

            # Create restart intent
            activity = PythonActivity.mActivity
            intent = Intent(activity.getApplicationContext(), PythonActivity)
            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)

            # Start new activity and exit current one
            activity.startActivity(intent)
            System.exit(0)

        except Exception as e:
            Logger.error(f'Failed to restart Android app: {e}')
            raise

    # ==================== UTILITY FUNCTIONS ====================

    def bind_key(self, key, callback):
        """
        Bind a keyboard shortcut for manual reload.

        Enables Ctrl+R functionality when using scrcpy for Android debugging.

        Args:
            key: The keycode to bind (e.g., CTRL_R_KEYCODE)
            callback: The function to call when the key combination is pressed
        """

        def _on_keyboard(window, keycode, scancode, codepoint, modifier_keys):
            pressed_modifiers = set(modifier_keys)

            if key == keycode and 'ctrl' in pressed_modifiers:
                return callback()

        Window.bind(on_keyboard=_on_keyboard)

    def get_hash_of_file(self, file_name):
        """
        Calculate MD5 hash of a file for change detection.

        Args:
            file_name: Path to the file to hash

        Returns:
            MD5 hash string or None if file cannot be read
        """
        try:
            with open(file_name, 'rb') as f:
                return hashlib.md5(f.read()).hexdigest()
        except (FileNotFoundError, PermissionError) as e:
            Logger.warning(f'Unable to hash file {file_name}: {e}')
            return None

    def _unregister_factory_from_module(self, module):
        """
        Unregister all Factory classes from a specific module.

        Used during hot reload to clean up widget registrations
        before reloading the module.

        Args:
            module: The module name to unregister classes from
        """
        to_remove = [x for x in F.classes if F.classes[x]['module'] == module]

        # Check class names by module attribute
        for class_name in F.classes:
            cls = F.classes[class_name]['cls']
            if not cls:
                continue
            if getattr(cls, '__module__', None) == module:
                to_remove.append(class_name)

        # Remove all identified classes
        for name in set(to_remove):
            del F.classes[name]

    def recompile_main(self):
        """
        Recompile main.py on Android to ensure latest bytecode.

        Removes existing main.pyc and recompiles main.py to prevent
        Android from using stale bytecode during hot reload.
        """
        if platform != 'android':
            return

        files = os.listdir()
        if 'main.pyc' in files and 'main.py' in files:
            Logger.info('Deleting main.pyc')
            os.remove('main.pyc')
            Logger.info('Compiling main.py')
            main_py_path = os.path.join(os.getcwd(), 'main.py')
            import py_compile

            try:
                Logger.info(f'Reloader: Compiling main.py: {main_py_path}')
                py_compile.compile(main_py_path, doraise=True)
            except py_compile.PyCompileError as e:
                Logger.error(f'Failed to compile main.py: {e}')

    # ==================== HOT RELOAD CORE ====================

    def reload_app(self, *args):
        """
        Reloads the whole application:
        - KV files, services, and Python modules.

        Handles multiple types of reload scenarios:
        - Service file changes (triggers app restart)
        - Full reload files (triggers app restart)
        - Main.py changes (triggers app restart)
        - KV file changes (hot reload)
        """
        Logger.info('Reloading app')

        # Check for service updates that require app restart
        if self._check_and_handle_service_updates():
            return

        # Check for full reload file updates
        if self._check_full_reload_files():
            return

        # Check for main.py changes
        if self._check_main_py_changes():
            return

        # Handle KV file hot reload
        self._handle_kv_file_reload()

        # Rebuild the UI
        self.build_root_and_add_to_window()

    def _check_and_handle_service_updates(self):
        """
        Check for service file changes and handle service restarts.

        Returns:
            bool: True if app restart was triggered, False otherwise
        """
        for service_name, file_name in zip(config.SERVICE_NAMES, config.SERVICE_FILES):
            if self.get_hash_of_file(file_name) != self.service_files_hashes[file_name]:
                Logger.info(f'Service {service_name} has been updated')

                # Handle service file recompilation
                self._recompile_service_file(file_name)

                # Stop the service
                if self._stop_android_service(service_name):
                    self.restart_app_on_android()
                    return True

        return False

    def _recompile_service_file(self, file_name):
        """Recompile a service file by removing bytecode and recompiling."""
        compiled_file = f'{file_name}c'
        if os.path.exists(compiled_file):
            os.remove(compiled_file)

        import py_compile

        try:
            Logger.info(f'Reloader: Recompiling service file: {file_name}')
            py_compile.compile(file_name, doraise=True)
        except py_compile.PyCompileError as e:
            Logger.error(f'Failed to compile {file_name}: {e}')

    def _stop_android_service(self, service_name):
        """
        Stop an Android service.

        Returns:
            bool: True if service was stopped successfully, False otherwise
        """
        Logger.info(f'Stopping service {service_name}')

        if not JNIUS_AVAILABLE:
            Logger.error('jnius not available for service management')
            return False

        try:
            mActivity = autoclass('org.kivy.android.PythonActivity').mActivity
            context = mActivity.getApplicationContext()
            service_class_name = (
                str(context.getPackageName()) + '.Service' + service_name
            )
            service = autoclass(service_class_name)
            service.stop(mActivity)
            return True
        except Exception as e:
            Logger.error(f'Failed to stop service {service_name}: {e}')
            return False

    def _check_full_reload_files(self):
        """
        Check for changes in files that require full app reload.

        Returns:
            bool: True if app restart was triggered, False otherwise
        """
        for file_name in config.FULL_RELOAD_FILES:
            if not os.path.exists(file_name):
                Logger.info(f'Reloader: File {file_name} does not exist. Skipping!')
                continue

            if file_name not in self.full_reload_file_hashes:
                self.full_reload_file_hashes[file_name] = self.get_hash_of_file(
                    file_name
                )
                continue

            current_hash = self.get_hash_of_file(file_name)
            if current_hash != self.full_reload_file_hashes[file_name]:
                Logger.info(
                    f'Reloader: File {file_name} has been updated. Restarting app...'
                )
                self.restart_app_on_android()
                return True

        return False

    def _check_main_py_changes(self):
        """
        Check for main.py changes that require app restart.

        Returns:
            bool: True if app restart was triggered, False otherwise
        """
        main_py_file_path = os.path.join(os.getcwd(), 'main.py')
        if self.main_py_hash is None:
            Logger.info('main.py hash not initialized, skipping check')
            if os.path.exists(main_py_file_path):
                self.main_py_hash = self.get_hash_of_file(main_py_file_path)
            return False

        if os.path.exists(main_py_file_path):
            current_hash = self.get_hash_of_file(main_py_file_path)
            if current_hash != self.main_py_hash:
                Logger.info('main.py changed, restarting app')
                Logger.info(
                    'current_hash: %s, previous_hash: %s',
                    current_hash,
                    self.main_py_hash,
                )
                self.restart_app_on_android()
                return True

        return False

    def _handle_kv_file_reload(self):
        """Handle hot reload of KV files without app restart."""
        current_kv_files_hashes = {
            file_name: self.get_hash_of_file(file_name)
            for file_name in get_kv_files_paths()
        }

        if current_kv_files_hashes != self.kv_files_hashes:
            kv_files_that_changed = [
                file_name
                for file_name in current_kv_files_hashes
                if current_kv_files_hashes[file_name]
                != self.kv_files_hashes.get(file_name, None)
            ]

            for file_name in kv_files_that_changed:
                Logger.info(f'Reloading KV file: {file_name}')
                Builder.unload_file(file_name)
                Builder.load_file(file_name)

            # Update stored hashes
            self.kv_files_hashes = current_kv_files_hashes

    # ==================== PYTHON MODULE MANAGEMENT ====================

    def unload_python_file(self, filename, module):
        """
        Unload a Python file from the module system for hot reload.

        Args:
            filename: The file path to unload
            module: The module name to unload

        Returns:
            The module object if successfully unloaded, None otherwise
        """
        if module == 'main':
            return None

        if module in sys.modules:
            full_path = os.path.join(os.getcwd(), filename)
            F.unregister_from_filename(full_path)
            self._unregister_factory_from_module(module)
            return sys.modules[module]

        return None

    def gather_files_to_reload(self, folders, recursive=False):
        """
        Gather Python files from specified folders for reloading.

        Args:
            folders: List of folder paths to search
            recursive: Whether to search folders recursively

        Returns:
            List of Python file paths to reload
        """
        files_to_reload = []
        for folder in folders:
            if recursive:
                for root, _, files in os.walk(folder):
                    files_to_reload.extend(
                        os.path.join(root, file)
                        for file in files
                        if file.endswith('.py')
                    )
            else:
                files_to_reload.extend(
                    os.path.join(folder, file)
                    for file in os.listdir(folder)
                    if file.endswith('.py')
                )
        return files_to_reload

    def process_unload_files(self, files):
        """
        Process a list of files for unloading and collect modules to reload.

        Args:
            files: List of file paths to process

        Returns:
            List of module objects that need to be reloaded
        """
        modules_to_reload = []
        for filename in files:
            module_name = os.path.relpath(filename).replace(os.path.sep, '.')[:-3]
            to_reload = self.unload_python_file(filename, module_name)
            if to_reload is not None:
                modules_to_reload.append(to_reload)

        return modules_to_reload

    def unload_python_files_on_android(self):
        """
        Unload and reload Python files on Android with optimized batch processing.

        Gathers files from various watched locations, unloads them, and performs
        a multi-pass reload to handle module dependencies correctly.
        """
        files_to_reload = self._gather_all_watched_files()
        modules_to_reload = self.process_unload_files(files_to_reload)

        # Performance optimization: Only reload if we have modules to reload
        if not modules_to_reload:
            Logger.info('Reloader: No modules to reload, skipping reload step')
            return

        self._perform_module_reload(modules_to_reload)

    def _gather_all_watched_files(self):
        """Gather all files from all watched locations."""
        files_to_reload = []

        # Gather files from recursively watched folders
        files_to_reload.extend(
            self.gather_files_to_reload(
                config.WATCHED_FOLDERS_RECURSIVELY, recursive=True
            )
        )

        # Gather files from watched folders
        files_to_reload.extend(self.gather_files_to_reload(config.WATCHED_FOLDERS))

        # Add individual watched files
        files_to_reload.extend(
            os.path.join(os.getcwd(), file) for file in config.WATCHED_FILES
        )

        # Add files that require full reload
        files_to_reload.extend(
            os.path.join(os.getcwd(), file) for file in config.FULL_RELOAD_FILES
        )

        return files_to_reload

    def _perform_module_reload(self, modules_to_reload):
        """
        Perform multi-pass module reloading to handle dependencies.

        We need multiple passes because module dependencies may require
        a specific loading order that we can't determine in advance.
        """
        Logger.info(
            f'Reloader: Reloading {len(modules_to_reload)} modules '
            f'({MODULE_RELOAD_PASSES} passes)'
        )

        for pass_num in range(MODULE_RELOAD_PASSES):
            for module in modules_to_reload:
                try:
                    importlib.reload(module)
                except Exception as e:
                    Logger.warning(
                        f'Failed to reload {module} on pass {pass_num + 1}: {e}'
                    )

    # ==================== NETWORK SERVER ====================

    def initialize_server(self):
        """
        Initialize and start the TCP server for receiving hot reload updates.

        The server runs in the background to receive file updates from
        the desktop development environment.
        """
        self.nursery.start_soon(self.start_async_server)

    async def start_async_server(self):
        """
        Start the async TCP server to listen for hot reload updates.

        The server listens on the configured port and automatically
        discovers the device's IP address for network communication.
        """
        PORT = config.RELOADER_PORT

        try:
            # Discover device IP address
            device_ip = self._get_device_ip()
            Logger.info(f'Smartphone IP: {device_ip}')

            # Start TCP server
            await trio.serve_tcp(self.data_receiver, PORT)

        except Exception as e:
            self._log_server_startup_error(e)

    def _get_device_ip(self):
        """
        Get the device's IP address by connecting to a remote server.

        Returns:
            str: The device's local IP address
        """
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
            probe.connect((DNS_SERVER_IP, DNS_SERVER_PORT))
            return probe.getsockname()[0]

    def _log_server_startup_error(self, error):
        """Log detailed error information when server fails to start."""
        Logger.info(
            'It was not possible to start the server, check if the '
            'phone is connected to the same network as the computer'
        )
        Logger.info(
            'Another possible cause is that the port is already in use '
            'by another app. Check if the port is free and try again'
        )
        Logger.info(f'Error details: {error}')

    async def data_receiver(self, data_stream):
        """
        Handle incoming data from the desktop development environment.

        Receives a zip file containing the updated application code,
        unpacks it, and triggers a hot reload of the application.

        Args:
            data_stream: The incoming TCP data stream
        """
        Logger.info('Reloader: ************** SERVER **************')
        Logger.info('Reloader: Server started: receiving data from computer...')

        try:
            # Ensure only one update is applied at a time
            async with self._update_lock:
                # Use a unique filename per connection to prevent collisions
                zip_file_path = os.path.join(os.getcwd(), f'app_copy_{uuid4().hex}.zip')

                # Receive and save the zip file
                await self._receive_zip_file(data_stream, zip_file_path)

                # Process the received update
                await self._process_app_update(zip_file_path)

                # Send ACK back to the desktop after successful processing
                try:
                    await data_stream.send_all(b'OK')
                except Exception as ack_err:
                    Logger.warning(
                        f'Reloader: Failed to send ACK to desktop: {ack_err}'
                    )

        except Exception as e:
            self._log_server_error(e)

    async def _receive_zip_file(self, data_stream, zip_file_path):
        """
        Receive zip file data from the TCP stream.

        Args:
            data_stream: The incoming TCP data stream
            zip_file_path: Destination path where to write the received zip

        Returns:
            str: Path to the saved zip file
        """
        with open(zip_file_path, 'wb') as zip_file:
            Logger.info('Reloader: Server: receiving data')
            async for data in data_stream:
                Logger.info(f'Reloader: Data size: {len(data)}')
                # Data arrives in chunks until client half-closes
                zip_file.write(data)
        return zip_file_path

    async def _process_app_update(self, zip_file_path):
        """
        Process the received app update (delta or full) and trigger reload.

        Args:
            zip_file_path: Path to the received zip file
        """
        Logger.info('Reloader: Finished receiving all files from computer')
        Logger.info('Reloader: Processing app update')

        # Log zip file information and extract
        # Check if this is a delta or full transfer
        transfer_type = self._detect_transfer_type(zip_file_path)

        # Log transfer metadata
        self._log_transfer_metadata(zip_file_path, transfer_type)

        if transfer_type == 'delta':
            self._process_delta_update(zip_file_path)
        else:
            # Full transfer - extract everything and handle deletions
            self._process_full_update(zip_file_path)

        # Best-effort cleanup of temp file
        try:
            if os.path.exists(zip_file_path):
                os.remove(zip_file_path)
        except Exception as e:
            Logger.warning(f'Reloader: Failed to remove temp file {zip_file_path}: {e}')

        Logger.info('Reloader: App updated, triggering hot reload')
        Logger.info('Reloader: ************** END SERVER **************')

        # Trigger hot reload
        self.unload_python_files_on_android()
        if self.__module__ != '__main__':
            importlib.reload(importlib.import_module(self.__module__))

        self.reload_app()

    def _detect_transfer_type(self, zip_file_path):
        """
        Detect if the received archive is a delta or full transfer.

        Args:
            zip_file_path: Path to the zip file

        Returns:
            str: 'delta' or 'full'
        """
        try:
            with zipfile.ZipFile(zip_file_path, 'r') as zip_file:
                if '_delta_metadata.json' in zip_file.namelist():
                    metadata_content = zip_file.read('_delta_metadata.json')
                    metadata = json.loads(metadata_content.decode('utf-8'))
                    return metadata.get('type', 'full')
        except (zipfile.BadZipFile, json.JSONDecodeError, KeyError):
            Logger.warning('Could not detect transfer type, assuming full transfer')

        return 'full'

    def _log_transfer_metadata(self, zip_file_path, transfer_type):
        """Log detailed information about the transfer metadata."""
        try:
            with zipfile.ZipFile(zip_file_path, 'r') as zip_file:
                if '_delta_metadata.json' in zip_file.namelist():
                    metadata_content = zip_file.read('_delta_metadata.json')
                    metadata = json.loads(metadata_content.decode('utf-8'))

                    if transfer_type == 'delta':
                        changed_files = metadata.get('files', [])
                        deleted_files = metadata.get('deleted_files', [])

                        Logger.info(
                            f'Android: Received {transfer_type} transfer - '
                            f'{len(changed_files)} changed, '
                            f'{len(deleted_files)} deleted files'
                        )

                        # Log changed files as tree
                        if changed_files:
                            Logger.info(
                                format_file_tree(
                                    changed_files, 'Android: Changed files'
                                )
                            )

                        # Log deleted files as tree
                        if deleted_files:
                            Logger.info(
                                format_file_tree(
                                    deleted_files, 'Android: Files to delete'
                                )
                            )
                    else:
                        all_files = metadata.get('files', [])
                        Logger.info(
                            f'Android: Received {transfer_type} transfer '
                            f'with {len(all_files)} files'
                        )

                        # Log complete project state from desktop
                        if all_files:
                            Logger.info(
                                format_file_tree(
                                    all_files, 'Android: Complete project from desktop'
                                )
                            )
                else:
                    # Legacy transfer without metadata
                    file_count = len(zip_file.namelist())
                    Logger.info(
                        f'Android: Received {transfer_type} transfer '
                        f'with {file_count} files'
                    )
        except Exception as e:
            Logger.warning(f'Android: Failed to read transfer metadata: {e}')

    def _process_delta_update(self, zip_file_path):
        """
        Process a delta update by extracting only changed files.

        Args:
            zip_file_path: Path to the delta zip file
        """
        with zipfile.ZipFile(zip_file_path, 'r') as zip_file:
            # Read metadata
            metadata_content = zip_file.read('_delta_metadata.json')
            metadata = json.loads(metadata_content.decode('utf-8'))

            Logger.info(f'Delta metadata: {metadata}')

            changed_files = metadata.get('files', [])
            deleted_files = metadata.get('deleted_files', [])

            Logger.info(
                f'Delta update: {len(changed_files)} changed, '
                f'{len(deleted_files)} deleted'
            )

            # Extract changed files
            for file_path in changed_files:
                if file_path in zip_file.namelist():
                    # Extract to current directory
                    zip_file.extract(file_path, os.getcwd())
                    Logger.debug(f'Updated: {file_path}')
                else:
                    Logger.warning(f'File not found in zip: {file_path}')
                    Logger.warning(f'Current zip_file contents: {zip_file.namelist()}')
                    Logger.warning(f'Current changed_files: {changed_files}\n')

            # Delete removed files
            for file_path in deleted_files:
                full_path = os.path.join(os.getcwd(), file_path)
                if os.path.exists(full_path):
                    os.remove(full_path)
                    Logger.debug(f'Deleted: {file_path}')

    def _process_full_update(self, zip_file_path):
        """
        Process a full update by synchronizing Android file system with desktop state.

        Full update process:
        1. Scan current Android file system
        2. Extract new files from desktop
        3. Delete files that no longer exist on desktop

        Args:
            zip_file_path: Path to the ZIP file containing new desktop state
        """
        # Step 1: Snapshot current Android file system
        files_on_android_before = self._get_current_project_files()
        Logger.info('Full update: Scanning current Android file system')
        Logger.info(
            tree_formatter.format_file_tree(
                files_on_android_before, 'Android: Files currently on device'
            )
        )

        # Step 2: Extract new desktop state
        with zipfile.ZipFile(zip_file_path, 'r') as zip_file:
            # Get list of files that should exist after update (new desktop state)
            new_desktop_state = set()
            for file_info in zip_file.filelist:
                if not file_info.filename.startswith('_delta_metadata.json'):
                    new_desktop_state.add(file_info.filename)

            # Extract all files (overwrites existing + adds new)
            zip_file.extractall(os.getcwd())
            Logger.info(
                f'Full update: Extracted {len(new_desktop_state)} files from desktop'
            )

            # Step 3: Clean up obsolete files
            # Files to delete = files that exist on Android but not in new desktop state
            obsolete_files = files_on_android_before - new_desktop_state

            if obsolete_files:
                Logger.info(
                    tree_formatter.format_file_tree(
                        obsolete_files, 'Android: Obsolete files to delete'
                    )
                )

                # Delete obsolete files
                deleted_count = 0
                for file_path in obsolete_files:
                    full_path = os.path.join(os.getcwd(), file_path)
                    if os.path.exists(full_path):
                        try:
                            os.remove(full_path)
                            deleted_count += 1
                            Logger.debug(f'Deleted obsolete file: {file_path}')
                        except OSError as e:
                            Logger.warning(f'Failed to delete {file_path}: {e}')

                Logger.info(f'Full update: Deleted {deleted_count} obsolete files')
            else:
                Logger.info('Full update: No obsolete files to delete')

    def _get_current_project_files(self):
        """
        Get current project files using same exclusion logic as delta transfer.

        Returns:
            set: Set of relative file paths that are part of the project
        """
        exclude_patterns = config.FOLDERS_AND_FILES_TO_EXCLUDE_FROM_PHONE + [
            '.kivy',
            'libpybundle.version',
            'p4a_env_vars.txt',
            '_delta_metadata.json',
        ]
        project_files = set()

        for root, dirs, files in os.walk(os.getcwd()):
            # Convert to relative path
            rel_root = os.path.relpath(root, os.getcwd())
            if rel_root == '.':
                rel_root = ''

            # Filter directories based on exclude patterns
            dirs[:] = [
                d
                for d in dirs
                if not self._should_exclude_path(
                    os.path.join(rel_root, d) if rel_root else d, exclude_patterns
                )
            ]

            for file in files:
                if rel_root:
                    rel_path = os.path.join(rel_root, file).replace('\\', '/')
                else:
                    rel_path = file

                if not self._should_exclude_path(rel_path, exclude_patterns):
                    project_files.add(rel_path)

        return project_files

    def _should_exclude_path(self, rel_path, exclude_patterns):
        """Check if a path should be excluded using fnmatch patterns."""
        path_str = rel_path.replace('\\', '/')
        rel_path_obj = Path(rel_path)

        for pattern in exclude_patterns:
            # Handle directory patterns (ending with /)
            if pattern.endswith('/'):
                clean_pattern = pattern.rstrip('/')
                if fnmatch(path_str, f'{clean_pattern}/*') or path_str == clean_pattern:
                    return True
            # Handle file patterns
            elif fnmatch(path_str, pattern) or fnmatch(rel_path_obj.name, pattern):
                return True

        return False

    def _log_server_error(self, error):
        """Log server error with full traceback."""
        Logger.info(f'Reloader: Server crashed: {error!r}')
        Logger.info(
            'Full exception:',
            ''.join(traceback.format_exception(*sys.exc_info())),
        )
