# 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.

"""
import errno
import os
import os.path
import select
from threading import Lock

from qtpy import QtCore
from qtpy.QtCore import Signal

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

AVAILABLE = None

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

        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, thread_class):
        QtCore.QObject.__init__(self)
        self.context = context
        self._thread_class = thread_class
        self._thread = None

    def start(self):
        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):
        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):
        if self._thread is not None:
            self._thread.refresh()


class _BaseThread(QtCore.QThread):
    #: The delay, in milliseconds, between detecting file system modification
    #: and triggering the 'files_changed' signal, to coalesce multiple
    #: modifications into a single signal.
    _NOTIFICATION_DELAY = 888

    def __init__(self, context, monitor):
        QtCore.QThread.__init__(self)
        self.context = 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()

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

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

    def notify(self):
        """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():
        msg = N_('File system change monitoring: enabled.\n')
        Interaction.log(msg)


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, monitor):
            _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 = None
            self._pipe_w = 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():
            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):
            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):
            while self._running:
                if self._pending:
                    timeout = self._NOTIFICATION_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):
            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):
            with self._lock:
                self._refresh()

        def _refresh(self):
            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, wd_to_path_map, path_to_wd_map):
            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):
            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):
            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):
            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):
            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):
            """Append our event to the events list when valid"""
            if self.event is not None:
                events.append(self.event)

        def _start(self):
            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):
            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):
            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, monitor):
            _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 = None
            self._git_dir = self._transform_path(core.abspath(git.git_path()))
            self._git_dir_watch = None
            self._stop_event_lock = Lock()
            self._stop_event = None

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

        def run(self):
            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._NOTIFICATION_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):
            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):
            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):
    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)
