#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
Filesystem Service Implementation using Connect RPC

Cross-platform support:
- Unix: Uses pwd and grp for user/group info
- Windows: Uses win32security via platform abstraction layer
"""

import asyncio
import os
import shutil
import stat
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Tuple

from google.protobuf.timestamp_pb2 import Timestamp
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer

# Platform abstraction for cross-platform user/group info
from executor.platform_compat import get_user_info_provider
from shared.logger import setup_logger

# Will be generated by generate.sh
try:
    from ..gen.filesystem.filesystem import filesystem_pb2
except ImportError:
    filesystem_pb2 = None

logger = setup_logger("filesystem_service")


class ConnectError(Exception):
    """Connect RPC error"""

    def __init__(self, code: str, message: str):
        self.code = code
        self.message = message
        super().__init__(message)


class WatcherEventHandler(FileSystemEventHandler):
    """Handler for file system events"""

    def __init__(self, event_queue: asyncio.Queue):
        self.event_queue = event_queue

    def on_created(self, event):
        self.event_queue.put_nowait(
            filesystem_pb2.FilesystemEvent(
                name=event.src_path,
                type=filesystem_pb2.EVENT_TYPE_CREATE,
            )
        )

    def on_modified(self, event):
        self.event_queue.put_nowait(
            filesystem_pb2.FilesystemEvent(
                name=event.src_path,
                type=filesystem_pb2.EVENT_TYPE_WRITE,
            )
        )

    def on_deleted(self, event):
        self.event_queue.put_nowait(
            filesystem_pb2.FilesystemEvent(
                name=event.src_path,
                type=filesystem_pb2.EVENT_TYPE_REMOVE,
            )
        )

    def on_moved(self, event):
        self.event_queue.put_nowait(
            filesystem_pb2.FilesystemEvent(
                name=event.dest_path,
                type=filesystem_pb2.EVENT_TYPE_RENAME,
            )
        )


class WatcherManager:
    """Manages file system watchers"""

    def __init__(self):
        self.watchers: Dict[str, tuple[Observer, asyncio.Queue]] = {}

    def create_watcher(self, path: str, recursive: bool) -> str:
        """Create a new watcher and return its ID"""
        watcher_id = str(uuid.uuid4())
        event_queue = asyncio.Queue()

        observer = Observer()
        event_handler = WatcherEventHandler(event_queue)
        observer.schedule(event_handler, path, recursive=recursive)
        observer.start()

        self.watchers[watcher_id] = (observer, event_queue)
        return watcher_id

    def get_events(self, watcher_id: str) -> List[filesystem_pb2.FilesystemEvent]:
        """Get all pending events for a watcher"""
        if watcher_id not in self.watchers:
            return []

        _, event_queue = self.watchers[watcher_id]
        events = []

        while not event_queue.empty():
            try:
                events.append(event_queue.get_nowait())
            except asyncio.QueueEmpty:
                break

        return events

    def remove_watcher(self, watcher_id: str) -> bool:
        """Remove a watcher"""
        if watcher_id not in self.watchers:
            return False

        observer, _ = self.watchers[watcher_id]
        observer.stop()
        observer.join()
        del self.watchers[watcher_id]
        return True

    async def get_event_stream(self, watcher_id: str):
        """Get async generator for watcher events"""
        if watcher_id not in self.watchers:
            return

        _, event_queue = self.watchers[watcher_id]

        while watcher_id in self.watchers:
            try:
                event = await asyncio.wait_for(event_queue.get(), timeout=1.0)
                yield event
            except asyncio.TimeoutError:
                # Send keepalive
                yield None


class FilesystemServiceHandler:
    """Handler for Filesystem service RPC methods"""

    def __init__(self):
        self.watcher_manager = WatcherManager()

    def _follow_symlink(self, path: str) -> str:
        """
        Resolve a symbolic link to its target path.
        Returns the resolved path or raises ConnectError.
        """
        try:
            resolved_path = os.path.realpath(path)
            return resolved_path
        except OSError as e:
            if not os.path.exists(path):
                raise ConnectError(
                    code="not_found",
                    message=f"Path not found: {path}",
                )

            # Check for cyclic symlinks
            error_str = str(e)
            if "Too many levels of symbolic links" in error_str or "ELOOP" in error_str:
                raise ConnectError(
                    code="failed_precondition",
                    message=f"Cyclic symlink or chain >255 links at '{path}'",
                )

            raise ConnectError(
                code="internal",
                message=f"Error resolving symlink: {str(e)}",
            )

    def _check_if_directory(self, path: str) -> None:
        """
        Check if the given path is a directory.
        Raises ConnectError if not a directory or path doesn't exist.
        """
        try:
            stat_info = os.stat(path)
        except OSError as e:
            if not os.path.exists(path):
                raise ConnectError(
                    code="not_found",
                    message=f"Directory not found: {path}",
                )
            raise ConnectError(
                code="internal",
                message=f"Error getting file info: {str(e)}",
            )

        if not os.path.isdir(path):
            raise ConnectError(
                code="invalid_argument",
                message=f"Path is not a directory: {path}",
            )

    def _walk_dir(
        self, requested_path: str, dir_path: str, depth: int
    ) -> List[filesystem_pb2.EntryInfo]:
        """
        Walk the directory tree starting from dir_path up to the specified depth.
        Does not follow symlinks during traversal.
        Returns entries with paths relative to requested_path.
        """
        entries = []

        try:
            for root, dirs, files in os.walk(dir_path, followlinks=False):
                # Calculate current depth
                rel_path = os.path.relpath(root, dir_path)
                if rel_path == ".":
                    current_depth = 0
                else:
                    current_depth = len(rel_path.split(os.sep))

                if current_depth >= depth:
                    # Don't descend into subdirectories
                    dirs.clear()
                    continue

                # Process files and directories at this level
                for name in dirs + files:
                    item_path = os.path.join(root, name)

                    try:
                        entry = self._get_entry_info(item_path)

                        # Return the requested path as the base path instead of the symlink-resolved path
                        item_rel_path = os.path.relpath(item_path, dir_path)
                        final_path = os.path.join(requested_path, item_rel_path)
                        entry.path = os.path.abspath(final_path)

                        entries.append(entry)
                    except ConnectError as e:
                        # Skip entries that don't exist anymore or we can't access
                        if e.code == "not_found":
                            logger.debug(f"Skipping non-existent entry: {item_path}")
                        else:
                            logger.warning(
                                f"Error getting info for {item_path}: {e.message}"
                            )
                    except Exception as e:
                        logger.warning(f"Error getting info for {item_path}: {str(e)}")

        except Exception as e:
            raise ConnectError(
                code="internal",
                message=f"Error reading directory {dir_path}: {str(e)}",
            )

        return entries

    def _get_entry_info(self, path: str) -> filesystem_pb2.EntryInfo:
        """Get EntryInfo for a path"""
        try:
            path_obj = Path(path)

            # Use lstat to get info about symlink itself, not target
            stat_info = path_obj.lstat()

            # Determine file type
            if path_obj.is_symlink():
                # For symlinks, we still report based on what the link points to
                # unless the link is broken
                try:
                    if path_obj.is_dir():
                        file_type = filesystem_pb2.FILE_TYPE_DIRECTORY
                    else:
                        file_type = filesystem_pb2.FILE_TYPE_FILE
                except (OSError, PermissionError):
                    # Broken symlink - treat as file
                    file_type = filesystem_pb2.FILE_TYPE_FILE
            elif path_obj.is_dir():
                file_type = filesystem_pb2.FILE_TYPE_DIRECTORY
            else:
                file_type = filesystem_pb2.FILE_TYPE_FILE

            # Get permissions string
            mode = stat_info.st_mode
            permissions = stat.filemode(mode)

            # Get owner and group using platform abstraction
            user_info = get_user_info_provider()
            owner = user_info.get_owner_name(stat_info.st_uid)
            group = user_info.get_group_name(stat_info.st_gid)

            # Get modified time (convert to UTC datetime with timezone info)
            modified_time = Timestamp()
            modified_time.FromDatetime(
                datetime.fromtimestamp(stat_info.st_mtime, tz=timezone.utc)
            )

            entry = filesystem_pb2.EntryInfo(
                name=path_obj.name,
                type=file_type,
                path=str(path_obj.absolute()),
                size=stat_info.st_size,
                mode=mode,
                permissions=permissions,
                owner=owner,
                group=group,
                modified_time=modified_time,
            )

            # Add symlink target if applicable
            if path_obj.is_symlink():
                try:
                    entry.symlink_target = str(path_obj.readlink())
                except (OSError, PermissionError) as e:
                    logger.debug(f"Could not read symlink target for {path}: {e}")

            return entry

        except FileNotFoundError:
            raise ConnectError(
                code="not_found",
                message=f"Path not found: {path}",
            )
        except PermissionError as e:
            raise ConnectError(
                code="permission_denied",
                message=f"Permission denied accessing path: {str(e)}",
            )
        except Exception as e:
            logger.exception(f"Error getting entry info for {path}: {e}")
            raise ConnectError(
                code="internal",
                message=f"Failed to get file info: {str(e)}",
            )

    async def Stat(
        self, request: filesystem_pb2.StatRequest
    ) -> filesystem_pb2.StatResponse:
        """Get file/directory information"""
        logger.debug(f"Stat request for path: {request.path}")

        # Expand and resolve the path
        path = os.path.abspath(os.path.expanduser(request.path))

        # Get entry info (this will raise ConnectError if path doesn't exist)
        entry = self._get_entry_info(path)
        return filesystem_pb2.StatResponse(entry=entry)

    async def MakeDir(
        self, request: filesystem_pb2.MakeDirRequest
    ) -> filesystem_pb2.MakeDirResponse:
        """Create a directory"""
        logger.info(f"MakeDir request for path: {request.path}")

        # Expand and resolve the path
        dir_path = os.path.abspath(os.path.expanduser(request.path))

        # Check if path already exists
        if os.path.exists(dir_path):
            stat_info = os.stat(dir_path)
            if os.path.isdir(dir_path):
                raise ConnectError(
                    code="already_exists",
                    message=f"Directory already exists: {dir_path}",
                )
            else:
                raise ConnectError(
                    code="invalid_argument",
                    message=f"Path already exists but it is not a directory: {dir_path}",
                )

        try:
            # Create directory with all intermediate directories
            os.makedirs(dir_path, exist_ok=False)
            entry = self._get_entry_info(dir_path)
            return filesystem_pb2.MakeDirResponse(entry=entry)
        except FileExistsError:
            # Race condition: directory was created between our check and makedirs
            raise ConnectError(
                code="already_exists",
                message=f"Directory already exists: {dir_path}",
            )
        except PermissionError as e:
            raise ConnectError(
                code="permission_denied",
                message=f"Permission denied creating directory: {str(e)}",
            )
        except Exception as e:
            logger.exception(f"Error creating directory {dir_path}: {e}")
            raise ConnectError(
                code="internal",
                message=f"Failed to create directory: {str(e)}",
            )

    async def Move(
        self, request: filesystem_pb2.MoveRequest
    ) -> filesystem_pb2.MoveResponse:
        """Move/rename a file or directory"""
        logger.info(f"Move request from {request.source} to {request.destination}")

        # Expand and resolve source path
        source = os.path.abspath(os.path.expanduser(request.source))

        # Expand and resolve destination path
        destination = os.path.abspath(os.path.expanduser(request.destination))

        # Ensure destination parent directory exists
        dest_parent = os.path.dirname(destination)
        if dest_parent and not os.path.exists(dest_parent):
            try:
                os.makedirs(dest_parent, exist_ok=True)
            except PermissionError as e:
                raise ConnectError(
                    code="permission_denied",
                    message=f"Permission denied creating destination directory: {str(e)}",
                )
            except Exception as e:
                raise ConnectError(
                    code="internal",
                    message=f"Error creating destination directory: {str(e)}",
                )

        # Perform the rename/move operation
        try:
            os.rename(source, destination)
        except FileNotFoundError:
            raise ConnectError(
                code="not_found",
                message=f"Source file not found: {source}",
            )
        except PermissionError as e:
            raise ConnectError(
                code="permission_denied",
                message=f"Permission denied moving file: {str(e)}",
            )
        except OSError as e:
            # Handle cross-device link errors
            if e.errno == 18:  # EXDEV - cross-device link
                try:
                    shutil.move(source, destination)
                except Exception as move_err:
                    raise ConnectError(
                        code="internal",
                        message=f"Error moving file across devices: {str(move_err)}",
                    )
            else:
                raise ConnectError(
                    code="internal",
                    message=f"Error renaming: {str(e)}",
                )
        except Exception as e:
            logger.exception(f"Error moving {source} to {destination}: {e}")
            raise ConnectError(
                code="internal",
                message=f"Failed to move file: {str(e)}",
            )

        # Get entry info for the moved file
        entry = self._get_entry_info(destination)
        return filesystem_pb2.MoveResponse(entry=entry)

    async def ListDir(
        self, request: filesystem_pb2.ListDirRequest
    ) -> filesystem_pb2.ListDirResponse:
        """List directory contents"""
        depth = request.depth if request.depth > 0 else 1  # default depth to 1

        logger.debug(f"ListDir request for path: {request.path}, depth: {depth}")

        requested_path = request.path

        # Expand and resolve the path (resolve to absolute path)
        requested_path = os.path.abspath(os.path.expanduser(requested_path))

        # Follow symlink to get the actual directory to scan
        resolved_path = self._follow_symlink(requested_path)

        # Check if the resolved path is a directory
        self._check_if_directory(resolved_path)

        # Walk the directory tree
        entries = self._walk_dir(requested_path, resolved_path, depth)

        return filesystem_pb2.ListDirResponse(entries=entries)

    async def Remove(
        self, request: filesystem_pb2.RemoveRequest
    ) -> filesystem_pb2.RemoveResponse:
        """Remove a file or directory"""
        logger.info(f"Remove request for path: {request.path}")

        # Expand and resolve the path
        path = os.path.abspath(os.path.expanduser(request.path))

        try:
            # Use shutil.rmtree for directories and os.remove for files
            # This is equivalent to Go's os.RemoveAll which removes everything
            if os.path.isdir(path):
                shutil.rmtree(path)
            else:
                os.remove(path)

            return filesystem_pb2.RemoveResponse()

        except FileNotFoundError:
            # Path doesn't exist - this could be okay for RemoveAll semantics
            # Go's RemoveAll returns nil for non-existent paths
            # But for consistency with explicit Remove, we'll raise an error
            raise ConnectError(
                code="not_found",
                message=f"Path not found: {path}",
            )
        except PermissionError as e:
            raise ConnectError(
                code="permission_denied",
                message=f"Permission denied removing path: {str(e)}",
            )
        except Exception as e:
            logger.exception(f"Error removing {path}: {e}")
            raise ConnectError(
                code="internal",
                message=f"Error removing file or directory: {str(e)}",
            )

    async def WatchDir(self, request: filesystem_pb2.WatchDirRequest):
        """Watch a directory for changes (streaming)"""
        logger.info(
            f"WatchDir request for path: {request.path}, recursive: {request.recursive}"
        )

        # Expand and resolve the path
        path = os.path.abspath(os.path.expanduser(request.path))

        if not os.path.exists(path):
            raise ConnectError(
                code="not_found",
                message=f"Path not found: {path}",
            )

        if not os.path.isdir(path):
            raise ConnectError(
                code="invalid_argument",
                message=f"Path is not a directory: {path}",
            )

        # Create watcher
        watcher_id = self.watcher_manager.create_watcher(path, request.recursive)

        try:
            # Send start event
            yield filesystem_pb2.WatchDirResponse(
                start=filesystem_pb2.WatchDirResponse.StartEvent()
            )

            # Stream events
            async for event in self.watcher_manager.get_event_stream(watcher_id):
                if event is None:
                    # Keepalive
                    yield filesystem_pb2.WatchDirResponse(
                        keepalive=filesystem_pb2.WatchDirResponse.KeepAlive()
                    )
                else:
                    # Filesystem event
                    yield filesystem_pb2.WatchDirResponse(filesystem=event)

        finally:
            # Clean up watcher
            self.watcher_manager.remove_watcher(watcher_id)

    async def CreateWatcher(
        self, request: filesystem_pb2.CreateWatcherRequest
    ) -> filesystem_pb2.CreateWatcherResponse:
        """Create a non-streaming watcher"""
        logger.info(
            f"CreateWatcher request for path: {request.path}, recursive: {request.recursive}"
        )

        # Expand and resolve the path
        path = os.path.abspath(os.path.expanduser(request.path))

        if not os.path.exists(path):
            raise ConnectError(
                code="not_found",
                message=f"Path not found: {path}",
            )

        if not os.path.isdir(path):
            raise ConnectError(
                code="invalid_argument",
                message=f"Path is not a directory: {path}",
            )

        watcher_id = self.watcher_manager.create_watcher(path, request.recursive)
        return filesystem_pb2.CreateWatcherResponse(watcher_id=watcher_id)

    async def GetWatcherEvents(
        self, request: filesystem_pb2.GetWatcherEventsRequest
    ) -> filesystem_pb2.GetWatcherEventsResponse:
        """Get events from a watcher"""
        logger.debug(f"GetWatcherEvents request for watcher: {request.watcher_id}")

        events = self.watcher_manager.get_events(request.watcher_id)
        return filesystem_pb2.GetWatcherEventsResponse(events=events)

    async def RemoveWatcher(
        self, request: filesystem_pb2.RemoveWatcherRequest
    ) -> filesystem_pb2.RemoveWatcherResponse:
        """Remove a watcher"""
        logger.info(f"RemoveWatcher request for watcher: {request.watcher_id}")

        success = self.watcher_manager.remove_watcher(request.watcher_id)
        if not success:
            raise ConnectError(
                code="not_found",
                message=f"Watcher not found: {request.watcher_id}",
            )

        return filesystem_pb2.RemoveWatcherResponse()
