#!/usr/bin/env python3
# -*- Mode: Python; coding: utf-8 -*-

# mkfifo /var/run/syncd.started
# syncd \
#   /var/run/syncd.anonhg \
#   '^/repo/git/[A-Za-z0-9_-]*' \
#   'echo >/var/run/syncd.started' \
#   'hg push --hidden --new-branch -r "heads(:)" anonhg' &
# timeout 10s sh -c 'read stuff </var/run/syncd.started'
# syncd \
#   /var/run/syncd.github \
#   '^/repo/git/[A-Za-z0-9_-]*' \
#   'echo >/var/run/syncd.started' \
#   'git push --mirror github' &
# timeout 10s sh -c 'read stuff </var/run/syncd.started'

import errno
import os
import re
import socket
import select
import subprocess
import sys


DEBUG = True if os.getenv('SYNCD_DEBUG') else False


def debug(msg):
    if DEBUG:
        sys.stderr.write(msg)


KQ_NOTE_DIRCHANGED = 0 \
    | select.KQ_NOTE_DELETE \
    | select.KQ_NOTE_EXTEND \
    | select.KQ_NOTE_LINK \
    | select.KQ_NOTE_RENAME \
    | select.KQ_NOTE_REVOKE \
    | select.KQ_NOTE_WRITE \
    # end of KQ_NOTE_DIRCHANGED


def deadmanswitch(
        sockpath: str,
        st: os.stat_result,
) -> bool:
    try:
        st1 = os.stat(sockpath)
    except OSError as e:
        if e.errno != errno.ENOENT:
            sys.stderr.write('stat failed: %s\n' % (e,))
        return True
    if st1.st_dev != st.st_dev or st1.st_ino != st.st_ino:
        return True
    return False


def sync(conn: socket.socket, dirpat: re.Pattern, cmd: str):
    try:
        pathbytes = conn.recv(os.pathconf('/', 'PC_PATH_MAX'))
        conn.close()
        try:
            path = pathbytes.decode('utf-8')
        except ValueError:
            debug('invalid path: %r\n' % (pathbytes,))
            return
        if not dirpat.match(path):
            debug('forbidden path: %r\n' % (path,))
            return
        p = subprocess.run(cmd, shell=True, cwd=path)
        if p.returncode != 0:
            debug('command exited with status %d\n' % (p.returncode,))
    except Exception as e:
        debug('sync error: %s\n' % (e,))


def main(argv: [str]):
    sockpath = argv[1]
    dirpatstr = argv[2]
    startcmd = argv[3]
    cmd = argv[4]

    # Compile the directory path.  If it doesn't start with `^', take
    # it relative to the working directory.
    #
    if not dirpatstr.startswith('^'):
        dirpatstr = '^' + re.escape(os.path.join(os.getcwd(), '')) + \
            '(' + dirpatstr + ')'
    debug('dirpat: %r\n' % (dirpatstr,))
    dirpat = re.compile(dirpatstr)

    # Unlink the socket -- if another syncd is running, it will
    # terminate once it notices this.
    #
    try:
        os.unlink(sockpath)
    except OSError as e:
        if e.errno != errno.ENOENT:
            sys.stderr.write('failed to unlink socket: %s\n' % (e,))
            sys.exit(1)

    # Open the directory where the socket lives so we can monitor it
    # for when the socket is unlinked.
    #
    d = os.open(os.path.dirname(sockpath) or '.', os.O_RDONLY | os.O_DIRECTORY)

    # Open a local stream socket and bind it, saving the stat result
    # (XXX racy), and expose it according to umask.  Make it
    # non-blocking: we will wait in kevent, not in accept.
    #
    sock = socket.socket(
        family=socket.AF_UNIX,
        type=socket.SOCK_STREAM | socket.SOCK_NONBLOCK,
    )
    sock.bind(sockpath)
    st = os.stat(sockpath)
    u = os.umask(0o777)
    os.umask(u)
    os.chmod(sockpath, 0o666 & ~u)

    # Start listening for connections.
    #
    sock.listen(1)

    # Start listening for:
    # - changes to the directory, so we can see when the socket is nixed
    # - connections on the socket
    #
    kq = select.kqueue()
    kq.control(
        [
            select.kevent(
                d,
                select.KQ_FILTER_VNODE,
                select.KQ_EV_ADD | select.KQ_EV_CLEAR,
                KQ_NOTE_DIRCHANGED,
                0,              # data
                0,              # udata
            ),
            select.kevent(
                sock.fileno(),
                select.KQ_FILTER_READ,
                select.KQ_EV_ADD | select.KQ_EV_CLEAR,
                0,              # filter flags
                0,              # data
                0,              # udata
            ),
        ],
        0,                      # max_events (don't return any events)
        0,                      # timeout (don't wait for anything)
    )

    # Notify the caller that we have started.  If this fails, give up.
    #
    p = subprocess.run(startcmd, shell=True)
    if p.returncode != 0:
        sys.stderr.write('start command exited %d\n' % (p.returncode,))
        sys.exit(p.returncode)

    # Loop forever, or until the socket is changed.
    #
    while True:

        # If we're dead, stop after accepting the last batch of
        # connections.
        #
        dead = deadmanswitch(sockpath, st)
        if dead:
            debug('dying\n')

        # Accept connections and process sync actions until there are
        # no more to accept.
        #
        while True:
            conn = None
            try:
                try:
                    conn, addr = sock.accept()
                except BlockingIOError:
                    break
                sync(conn, dirpat, cmd)
            finally:
                if conn is not None:
                    conn.close()
            del conn

        # If we were found to be dead, stop here.
        #
        if dead:
            debug('dead\n')
            break
        del dead

        # Wait until something has happened; then clear all pending
        # events so they don't build up.
        #
        for ev in kq.control([], 16, None):
            debug('event %r\n' % (ev,))
        while True:
            evlist = kq.control([], 16, 0)
            if not evlist:
                break
            for ev in evlist:
                debug('event %r\n' % (ev,))

    # Don't unlink the socket -- if another daemon started to replace
    # us, this would cause that one to exit.
    #
    return


if __name__ == '__main__':
    main(sys.argv)
