# Copyright (C) 2008-2024 David Aguilar
# Copyright (C) 2015 Daniel Harding
"""Filesystem monitor for Linux and Windows

Linux monitoring uses using inotify.
Windows monitoring uses pywin32 and the ReadDirectoryChanges function.

"""
from __future__ import annotations
import errno
import os
import os.path
import select
from threading import Lock
from typing import TYPE_CHECKING
from typing import Any

from qtpy import QtCore
from qtpy.QtCore import Signal

from . import core
from . import gitcmds
from . import utils
from . import version
from .compat import bchr
from .i18n import N_
from .interaction import Interaction
from .models import prefs

if TYPE_CHECKING:
    from .app import ApplicationContext

AVAILABLE = None

pywintypes = None
win32file = None
win32con = None
win32event = None
if utils.is_win32():
    try:
        import pywintypes  # type: ignore
        import win32con  # type: ignore
        import win32event  # type: ignore
        import win32file  # type: ignore

        AVAILABLE = 'pywin32'
    except ImportError:
        pass

elif utils.is_linux():
    try:
        from . import inotify
    except ImportError:
        pass
    else:
        AVAILABLE = 'inotify'


class _Monitor(QtCore.QObject):
    files_changed = Signal()
    config_changed = Signal()

    def __init__(
        self, context: ApplicationContext, thread_class: type[_InotifyThread]
    ) -> None:
        QtCore.QObject.__init__(self)
        self.context = context
        self._thread_class = thread_class
        self._thread: _InotifyThread | None = None

    def start(self) -> None:
        if self._thread_class is not None:
            assert self._thread is None
            self._thread = self._thread_class(self.context, self)
            self._thread.start()

    def stop(self) -> None:
        if self._thread_class is not None:
            assert self._thread is not None
            self._thread.stop()
            self._thread.wait()
            self._thread = None

    def refresh(self) -> None:
        if self._thread is not None:
            self._thread.refresh()


class _BaseThread(QtCore.QThread):
    def __init__(self, context: ApplicationContext, monitor: _Monitor) -> None:
        QtCore.QThread.__init__(self)
        self.context = context
        #: The delay, in milliseconds, between detecting file system modification
        #: and triggering the 'files_changed' signal, to coalesce multiple
        #: modifications into a single signal.
        self.inotify_delay = prefs.inotify_delay(context)
        self._monitor = monitor
        self._running = True
        self._use_check_ignore = version.check_git(context, 'check-ignore')
        self._force_notify = False
        self._force_config = False
        self._file_paths = set()

        self.context.cfg.repo_config_changed.connect(
            self._config_changed, QtCore.Qt.QueuedConnection
        )
        self.context.cfg.user_config_changed.connect(
            self._config_changed, QtCore.Qt.QueuedConnection
        )

    @property
    def _pending(self):
        return self._force_notify or self._file_paths or self._force_config

    def refresh(self) -> None:
        """Do any housekeeping necessary in response to repository changes."""
        return

    def notify(self) -> None:
        """Notifies all observers"""
        do_notify = False
        do_config = False
        if self._force_config:
            do_config = True
        if self._force_notify:
            do_notify = True
        elif self._file_paths:
            proc = core.start_command(
                ['git', 'check-ignore', '--verbose', '--non-matching', '-z', '--stdin']
            )
            path_list = bchr(0).join(core.encode(path) for path in self._file_paths)
            out, _ = proc.communicate(path_list)
            if proc.returncode:
                do_notify = True
            else:
                # Each output record is four fields separated by NULL
                # characters (records are also separated by NULL characters):
                # <source> <NULL> <linenum> <NULL> <pattern> <NULL> <pathname>
                # For paths which are not ignored, all fields will be empty
                # except for <pathname>.  So to see if we have any non-ignored
                # files, we simply check every fourth field to see if any of
                # them are empty.
                source_fields = out.split(bchr(0))[0:-1:4]
                do_notify = not all(source_fields)
        self._force_notify = False
        self._force_config = False
        self._file_paths = set()

        # "files changed" is a bigger hammer than "config changed".
        # and is a superset relative to what is done in response to the
        # signal.  Thus, the "elif" below avoids repeated work that
        # would be done if it were a simple "if" check instead.
        if do_notify:
            self._monitor.files_changed.emit()
        elif do_config:
            self._monitor.config_changed.emit()

    @staticmethod
    def _log_enabled_message() -> None:
        msg = N_('File system change monitoring: enabled.\n')
        Interaction.log(msg)

    def _config_changed(self, key, value) -> None:
        """Update cached configuation values"""
        if key == prefs.INOTIFY_DELAY:
            self.inotify_delay = prefs.inotify_delay(self.context)


if AVAILABLE == 'inotify':

    class _InotifyThread(_BaseThread):
        _TRIGGER_MASK = (
            inotify.IN_ATTRIB
            | inotify.IN_CLOSE_WRITE
            | inotify.IN_CREATE
            | inotify.IN_DELETE
            | inotify.IN_MODIFY
            | inotify.IN_MOVED_FROM
            | inotify.IN_MOVED_TO
        )
        _ADD_MASK = _TRIGGER_MASK | inotify.IN_EXCL_UNLINK | inotify.IN_ONLYDIR

        def __init__(self, context: ApplicationContext, monitor: _Monitor) -> None:
            _BaseThread.__init__(self, context, monitor)
            git = context.git
            worktree = git.worktree()
            if worktree is not None:
                worktree = core.abspath(worktree)
            self._worktree = worktree
            self._git_dir = git.git_path()
            self._lock = Lock()
            self._inotify_fd = None
            self._pipe_r: int | None = None
            self._pipe_w: int | None = None
            self._worktree_wd_to_path_map = {}
            self._worktree_path_to_wd_map = {}
            self._git_dir_wd_to_path_map = {}
            self._git_dir_path_to_wd_map = {}
            self._git_dir_wd = None

        @staticmethod
        def _log_out_of_wds_message() -> None:
            msg = N_(
                'File system change monitoring: disabled because the'
                ' limit on the total number of inotify watches was'
                ' reached.  You may be able to increase the limit on'
                ' the number of watches by running:\n'
                '\n'
                '    echo fs.inotify.max_user_watches=100000 |'
                ' sudo tee -a /etc/sysctl.conf &&'
                ' sudo sysctl -p\n'
            )
            Interaction.log(msg)

        def run(self) -> None:
            try:
                with self._lock:
                    try:
                        self._inotify_fd = inotify.init()
                    except OSError as e:
                        self._inotify_fd = None
                        self._running = False
                        if e.errno == errno.EMFILE:
                            self._log_out_of_wds_message()
                        return
                    self._pipe_r, self._pipe_w = os.pipe()

                poll_obj = select.poll()
                poll_obj.register(self._inotify_fd, select.POLLIN)
                poll_obj.register(self._pipe_r, select.POLLIN)

                self.refresh()

                if self._running:
                    self._log_enabled_message()
                    self._process_events(poll_obj)
            finally:
                self._close_fds()

        def _process_events(self, poll_obj) -> None:
            while self._running:
                if self._pending:
                    timeout = self.inotify_delay
                else:
                    timeout = None
                try:
                    events = poll_obj.poll(timeout)
                except OSError:
                    continue
                else:
                    if not self._running:
                        break
                    if not events:
                        self.notify()
                    else:
                        for fd, _ in events:
                            if fd == self._inotify_fd:
                                self._handle_events()

        def _close_fds(self) -> None:
            with self._lock:
                if self._inotify_fd is not None:
                    os.close(self._inotify_fd)
                    self._inotify_fd = None
                if self._pipe_r is not None:
                    os.close(self._pipe_r)
                    self._pipe_r = None
                    os.close(self._pipe_w)
                    self._pipe_w = None

        def refresh(self) -> None:
            with self._lock:
                self._refresh()

        def _refresh(self) -> None:
            if self._inotify_fd is None:
                return
            context = self.context
            try:
                if self._worktree is not None:
                    tracked_dirs = {
                        os.path.dirname(os.path.join(self._worktree, path))
                        for path in gitcmds.tracked_files(context)
                    }
                    self._refresh_watches(
                        tracked_dirs,
                        self._worktree_wd_to_path_map,
                        self._worktree_path_to_wd_map,
                    )
                git_dirs = set()
                git_dirs.add(self._git_dir)
                for dirpath, _, _ in core.walk(os.path.join(self._git_dir, 'refs')):
                    git_dirs.add(dirpath)
                self._refresh_watches(
                    git_dirs, self._git_dir_wd_to_path_map, self._git_dir_path_to_wd_map
                )
                self._git_dir_wd = self._git_dir_path_to_wd_map.get(self._git_dir)
            except OSError as e:
                if e.errno in (errno.ENOSPC, errno.EMFILE):
                    self._log_out_of_wds_message()
                    self._running = False
                else:
                    raise

        def _refresh_watches(
            self,
            paths_to_watch: set[str],
            wd_to_path_map: dict[int, str],
            path_to_wd_map: dict[str, int],
        ) -> None:
            watched_paths = set(path_to_wd_map)
            for path in watched_paths - paths_to_watch:
                wd = path_to_wd_map.pop(path)
                wd_to_path_map.pop(wd)
                try:
                    inotify.rm_watch(self._inotify_fd, wd)
                except OSError as e:
                    if e.errno == errno.EINVAL:
                        # This error can occur if the target of the watch was
                        # removed on the filesystem before we call
                        # inotify.rm_watch() so ignore it.
                        continue
                    raise e
            for path in paths_to_watch - watched_paths:
                try:
                    wd = inotify.add_watch(
                        self._inotify_fd, core.encode(path), self._ADD_MASK
                    )
                except PermissionError:
                    continue
                except OSError as e:
                    if e.errno in (errno.ENOENT, errno.ENOTDIR):
                        # These two errors should only occur as a result of
                        # race conditions:  the first if the directory
                        # referenced by path was removed or renamed before the
                        # call to inotify.add_watch(); the second if the
                        # directory referenced by path was replaced with a file
                        # before the call to inotify.add_watch().  Therefore we
                        # simply ignore them.
                        continue
                    raise e
                wd_to_path_map[wd] = path
                path_to_wd_map[path] = wd

        def _check_event(self, wd, mask, name) -> None:
            if mask & inotify.IN_Q_OVERFLOW:
                self._force_notify = True
            elif not mask & self._TRIGGER_MASK:
                pass
            elif mask & inotify.IN_ISDIR:
                pass
            elif wd in self._worktree_wd_to_path_map:
                if self._use_check_ignore and name:
                    path = os.path.join(
                        self._worktree_wd_to_path_map[wd], core.decode(name)
                    )
                    self._file_paths.add(path)
                else:
                    self._force_notify = True
            elif wd == self._git_dir_wd:
                name = core.decode(name)
                if name in ('HEAD', 'index'):
                    self._force_notify = True
                elif name == 'config':
                    self._force_config = True
            elif wd in self._git_dir_wd_to_path_map and not core.decode(name).endswith(
                '.lock'
            ):
                self._force_notify = True

        def _handle_events(self) -> None:
            for wd, mask, _, name in inotify.read_events(self._inotify_fd):
                if not self._force_notify:
                    self._check_event(wd, mask, name)

        def stop(self) -> None:
            self._running = False
            with self._lock:
                if self._pipe_w is not None:
                    os.write(self._pipe_w, bchr(0))
            self.wait()


if AVAILABLE == 'pywin32':

    class _Win32Watch:
        def __init__(self, path, flags) -> None:
            self.flags = flags

            self.handle = None
            self.event = None

            try:
                self.handle = win32file.CreateFileW(
                    path,
                    0x0001,  # FILE_LIST_DIRECTORY
                    win32con.FILE_SHARE_READ | win32con.FILE_SHARE_WRITE,
                    None,
                    win32con.OPEN_EXISTING,
                    win32con.FILE_FLAG_BACKUP_SEMANTICS | win32con.FILE_FLAG_OVERLAPPED,
                    None,
                )

                self.buffer = win32file.AllocateReadBuffer(8192)
                self.event = win32event.CreateEvent(None, True, False, None)
                self.overlapped = pywintypes.OVERLAPPED()
                self.overlapped.hEvent = self.event
                self._start()
            except Exception:
                self.close()

        def append(self, events) -> None:
            """Append our event to the events list when valid"""
            if self.event is not None:
                events.append(self.event)

        def _start(self) -> None:
            if self.handle is None:
                return
            try:
                win32file.ReadDirectoryChangesW(
                    self.handle, self.buffer, True, self.flags, self.overlapped
                )
            except pywintypes.error:
                pass

        def read(self) -> list[Any]:
            if self.handle is None or self.event is None:
                return []
            if win32event.WaitForSingleObject(self.event, 0) == win32event.WAIT_TIMEOUT:
                result = []
            else:
                nbytes = win32file.GetOverlappedResult(
                    self.handle, self.overlapped, False
                )
                result = win32file.FILE_NOTIFY_INFORMATION(self.buffer, nbytes)
                self._start()
            return result

        def close(self) -> None:
            if self.handle is not None:
                try:
                    win32file.CancelIo(self.handle)
                except pywintypes.error:
                    pass
                try:
                    win32file.CloseHandle(self.handle)
                except pywintypes.error:
                    pass
            if self.event is not None:
                try:
                    win32file.CloseHandle(self.event)
                except pywintypes.error:
                    pass

    class _Win32Thread(_BaseThread):
        _FLAGS = (
            win32con.FILE_NOTIFY_CHANGE_FILE_NAME
            | win32con.FILE_NOTIFY_CHANGE_DIR_NAME
            | win32con.FILE_NOTIFY_CHANGE_ATTRIBUTES
            | win32con.FILE_NOTIFY_CHANGE_SIZE
            | win32con.FILE_NOTIFY_CHANGE_LAST_WRITE
            | win32con.FILE_NOTIFY_CHANGE_SECURITY
        )

        def __init__(self, context: ApplicationContext, monitor) -> None:
            _BaseThread.__init__(self, context, monitor)
            git = context.git
            worktree = git.worktree()
            if worktree is not None:
                worktree = self._transform_path(core.abspath(worktree))
            self._worktree = worktree
            self._worktree_watch: _Win32Watch | None = None
            self._git_dir = self._transform_path(core.abspath(git.git_path()))
            self._git_dir_watch: _Win32Watch | None = None
            self._stop_event_lock = Lock()
            self._stop_event = None

        @staticmethod
        def _transform_path(path: str) -> str:
            return path.replace('\\', '/').lower()

        def run(self) -> None:
            try:
                with self._stop_event_lock:
                    self._stop_event = win32event.CreateEvent(None, True, False, None)

                events = [self._stop_event]

                if self._worktree is not None:
                    self._worktree_watch = _Win32Watch(self._worktree, self._FLAGS)
                    self._worktree_watch.append(events)

                self._git_dir_watch = _Win32Watch(self._git_dir, self._FLAGS)
                self._git_dir_watch.append(events)

                self._log_enabled_message()

                while self._running:
                    if self._pending:
                        timeout = self.inotify_delay
                    else:
                        timeout = win32event.INFINITE
                    try:
                        status = win32event.WaitForMultipleObjects(
                            events, False, timeout
                        )
                    except pywintypes.error:
                        self._running = False
                    if not self._running:
                        break
                    if status == win32event.WAIT_TIMEOUT:
                        self.notify()
                    else:
                        self._handle_results()
            finally:
                with self._stop_event_lock:
                    if self._stop_event is not None:
                        try:
                            win32file.CloseHandle(self._stop_event)
                        except pywintypes.error:
                            pass
                        self._stop_event = None
                if self._worktree_watch is not None:
                    self._worktree_watch.close()
                if self._git_dir_watch is not None:
                    self._git_dir_watch.close()

        def _handle_results(self) -> None:
            if self._worktree_watch is not None:
                for _, path in self._worktree_watch.read():
                    if not self._running:
                        break
                    if self._force_notify:
                        continue
                    path = self._worktree + '/' + self._transform_path(path)
                    if (
                        path != self._git_dir
                        and not path.startswith(self._git_dir + '/')
                        and not os.path.isdir(path)
                    ):
                        if self._use_check_ignore:
                            self._file_paths.add(path)
                        else:
                            self._force_notify = True
            for _, path in self._git_dir_watch.read():
                if not self._running:
                    break
                if self._force_notify:
                    continue
                path = self._transform_path(path)
                if path.endswith('.lock'):
                    continue
                if path == 'config':
                    self._force_config = True
                    continue
                if path == 'head' or path == 'index' or path.startswith('refs/'):
                    self._force_notify = True

        def stop(self) -> None:
            self._running = False
            with self._stop_event_lock:
                if self._stop_event is not None:
                    try:
                        win32event.SetEvent(self._stop_event)
                    except pywintypes.error:
                        pass
            self.wait()


def create(context: ApplicationContext) -> _Monitor:
    thread_class = None
    cfg = context.cfg
    if not cfg.get('cola.inotify', default=True):
        msg = N_(
            'File system change monitoring: disabled because'
            ' "cola.inotify" is false.\n'
        )
        Interaction.log(msg)
    elif AVAILABLE == 'inotify':
        thread_class = _InotifyThread
    elif AVAILABLE == 'pywin32':
        thread_class = _Win32Thread
    else:
        if utils.is_win32():
            msg = N_(
                'File system change monitoring: disabled because pywin32'
                ' is not installed.\n'
            )
            Interaction.log(msg)
        elif utils.is_linux():
            msg = N_(
                'File system change monitoring: disabled because libc'
                ' does not support the inotify system calls.\n'
            )
            Interaction.log(msg)
    return _Monitor(context, thread_class)
