#!/usr/bin/python3
"""Trigger the existing, serialized bisync job from local and Drive changes."""
import configparser
import ctypes
import datetime
import errno
import json
import os
from pathlib import Path
import queue
import select
import struct
import subprocess
import threading
import time

import requests

ROOT = Path.home() / '.local/share/gdrive-local'
CONFIG = Path.home() / '.config/rclone/rclone.conf'
STATE = Path.home() / '.local/state/gdrive-sync/watcher-status.json'
UNIT = 'gdrive-sync.service'
POLL_SECONDS = 10

def timestamp():
    return datetime.datetime.now().astimezone().isoformat()

class LocalWatch:
    # Linux inotify constants. Reads and directory timestamp updates are ignored.
    ATTRIB = 0x00000004
    CLOSE_WRITE = 0x00000008
    MOVED_FROM = 0x00000040
    MOVED_TO = 0x00000080
    CREATE = 0x00000100
    DELETE = 0x00000200
    DELETE_SELF = 0x00000400
    MOVE_SELF = 0x00000800
    OVERFLOW = 0x00004000
    IGNORED = 0x00008000
    ISDIR = 0x40000000
    MASK = ATTRIB | CLOSE_WRITE | MOVED_FROM | MOVED_TO | CREATE | DELETE | DELETE_SELF | MOVE_SELF

    def __init__(self, root):
        self.root = Path(root)
        self.libc = ctypes.CDLL(None, use_errno=True)
        self.libc.inotify_init1.argtypes = [ctypes.c_int]
        self.libc.inotify_init1.restype = ctypes.c_int
        self.libc.inotify_add_watch.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_uint32]
        self.libc.inotify_add_watch.restype = ctypes.c_int
        self.fd = -1
        self.paths = {}
        self.rebuild()

    def rebuild(self):
        if not self.root.is_dir():
            raise RuntimeError('Local Google Drive directory is missing.')
        new_fd = self.libc.inotify_init1(os.O_NONBLOCK | os.O_CLOEXEC)
        if new_fd < 0:
            raise OSError(ctypes.get_errno(), 'inotify_init1')
        new_paths = {}
        try:
            for directory, dirs, _ in os.walk(self.root, followlinks=False):
                dirs[:] = [d for d in dirs if not (Path(directory) / d).is_symlink()]
                wd = self.libc.inotify_add_watch(new_fd, os.fsencode(directory), self.MASK)
                if wd < 0:
                    err = ctypes.get_errno()
                    if err in (errno.ENOENT, errno.ENOTDIR):
                        continue
                    raise OSError(err, 'inotify_add_watch')
                new_paths[wd] = Path(directory)
        except BaseException:
            os.close(new_fd)
            raise
        old_fd = self.fd
        self.fd, self.paths = new_fd, new_paths
        if old_fd >= 0:
            os.close(old_fd)

    def read(self, timeout=1):
        if not select.select([self.fd], [], [], timeout)[0]:
            return False
        changed = rebuild = False
        while True:
            try:
                data = os.read(self.fd, 1024 * 1024)
            except BlockingIOError:
                break
            offset = 0
            while offset < len(data):
                wd, mask, _, length = struct.unpack_from('iIII', data, offset)
                offset += 16
                name = os.fsdecode(data[offset:offset + length].split(b'\0', 1)[0])
                offset += length
                if mask & self.OVERFLOW:
                    changed = rebuild = True
                    continue
                if mask & self.IGNORED:
                    continue
                if mask & self.ISDIR and mask & self.ATTRIB:
                    continue
                if mask & self.CREATE and not mask & self.ISDIR:
                    # Wait for close-write instead of starting while a new file
                    # is still being copied into the directory.
                    continue
                parent = self.paths.get(wd)
                if parent is None:
                    continue
                changed = True
                if mask & self.ISDIR or mask & (self.DELETE_SELF | self.MOVE_SELF):
                    rebuild = True
        if rebuild:
            self.rebuild()
        return changed

    def close(self):
        os.close(self.fd)
        self.fd = -1

class DriveChanges:
    def __init__(self):
        self.session = requests.Session()

    def get(self, path, params):
        for attempt in range(2):
            config = configparser.ConfigParser(interpolation=None)
            config.read(CONFIG)
            token = json.loads(config['gdrive']['token'])['access_token']
            response = self.session.get('https://www.googleapis.com/drive/v3/' + path,
                params=params, headers={'Authorization': 'Bearer ' + token, 'Cache-Control': 'no-cache'}, timeout=(5, 15))
            if response.status_code == 401 and attempt == 0:
                # Let rclone manage OAuth and its own config atomically. No copies
                # of refresh tokens or application credentials are stored here.
                subprocess.run(['/usr/bin/rclone', 'about', 'gdrive:', '--config', str(CONFIG), '--json'],
                    check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=45)
                continue
            response.raise_for_status()
            return response.json()
        raise RuntimeError('Google Drive authentication failed.')

    def start_token(self):
        return self.get('changes/startPageToken', {'fields': 'startPageToken'})['startPageToken']

    def recent(self):
        # A second discovery path covers uploads not yet exposed by changes.list.
        # Phone photos may retain an old modifiedTime, so include createdTime.
        cutoff = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=15)).strftime('%Y-%m-%dT%H:%M:%SZ')
        params = {'q': f"trashed = false and (createdTime > '{cutoff}' or modifiedTime > '{cutoff}')",
                  'spaces': 'drive', 'pageSize': 1000,
                  'fields': 'nextPageToken,files(id,version,createdTime,modifiedTime,size,md5Checksum)'}
        found = {}
        while True:
            page = self.get('files', params)
            for item in page.get('files', []):
                found[item['id']] = tuple(item.get(key) for key in
                    ('version', 'createdTime', 'modifiedTime', 'size', 'md5Checksum'))
            if not page.get('nextPageToken'):
                return found
            params['pageToken'] = page['nextPageToken']

    def page(self, token):
        return self.get('changes', {
            'pageToken': token, 'pageSize': 1000, 'spaces': 'drive',
            'restrictToMyDrive': 'true', 'includeRemoved': 'true',
            'fields': 'nextPageToken,newStartPageToken,changes(fileId,removed)',
        })

def cloud_worker(events):
    drive = DriveChanges()
    token = None
    backoff = POLL_SECONDS
    recent_seen = {}
    while True:
        try:
            if token is None:
                token = drive.start_token()
                # A full sync after taking the token covers changes during downtime.
                events.put(('cloud-baseline', timestamp()))
            recent_now = drive.recent()
            if any(recent_seen.get(ident) != signature for ident, signature in recent_now.items()):
                events.put(('cloud-recent-upload', timestamp()))
            recent_seen = recent_now
            events.put(('recent-check', timestamp()))
            while True:
                page = drive.page(token)
                if page.get('changes'):
                    events.put(('cloud-change', timestamp()))
                next_token = page.get('nextPageToken')
                token = next_token or page['newStartPageToken']
                if not next_token:
                    break
            events.put(('cloud-check', timestamp()))
            backoff = POLL_SECONDS
        except Exception as error:
            # No response bodies, filenames, page tokens or credentials in logs.
            code = getattr(getattr(error, 'response', None), 'status_code', None)
            events.put(('cloud-error', f'{type(error).__name__}' + (f' HTTP {code}' if code else '')))
            if code == 410:
                token = None
            backoff = min(60, backoff * 2)
        time.sleep(backoff)

class Triggers:
    def __init__(self):
        self.first_local = None
        self.local_due = None
        self.cloud_due = None
        self.reasons = set()

    def local(self, now):
        if self.first_local is None:
            self.first_local = now
        self.local_due = min(now + 2, self.first_local + 10)
        self.reasons.add('local-change')

    def cloud(self, now, reason):
        self.cloud_due = now
        self.reasons.add(reason)

    def due(self, now):
        return any(t is not None and t <= now for t in (self.local_due, self.cloud_due))

    def clear(self):
        self.first_local = self.local_due = self.cloud_due = None
        self.reasons.clear()

def sync_state():
    result = subprocess.run(['systemctl', '--user', 'show', UNIT, '-p', 'ActiveState', '--value'],
                            check=True, capture_output=True, text=True, timeout=5)
    return result.stdout.strip()

def main():
    os.umask(0o077)
    STATE.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
    watch = LocalWatch(ROOT)
    events = queue.SimpleQueue()
    threading.Thread(target=cloud_worker, args=(events,), daemon=True).start()
    trigger = Triggers()
    trigger.cloud(time.monotonic(), 'watcher-start')
    details = {'started': timestamp(), 'cloud_error': None, 'watched_directories': len(watch.paths)}
    last_save = 0
    next_start = 0
    print(f'Watching {len(watch.paths)} directories; Drive change check every {POLL_SECONDS}s.', flush=True)
    try:
        while True:
            if watch.read(timeout=1):
                trigger.local(time.monotonic())
                details['last_local_change'] = timestamp()
            while not events.empty():
                kind, value = events.get()
                if kind in ('cloud-change', 'cloud-baseline', 'cloud-recent-upload'):
                    trigger.cloud(time.monotonic(), kind)
                    details['last_cloud_change'] = value
                elif kind == 'recent-check':
                    details['last_recent_upload_check'] = value
                elif kind == 'cloud-check':
                    details['last_cloud_check'] = value
                    details['cloud_error'] = None
                elif kind == 'cloud-error':
                    if details.get('cloud_error') != value:
                        print('Drive change check temporarily unavailable:', value, flush=True)
                    details['cloud_error'] = value
            now = time.monotonic()
            if trigger.due(now) and now >= next_start:
                # All entrypoints use the same systemd oneshot and runner lock.
                # Changes received during a run stay pending for a following run.
                if sync_state() in ('inactive', 'failed'):
                    reasons = ', '.join(sorted(trigger.reasons))
                    subprocess.run(['systemctl', '--user', 'start', '--no-block', UNIT], check=True, timeout=5)
                    print('Requested synchronization:', reasons, flush=True)
                    details['last_sync_requested'] = timestamp()
                    details['last_sync_reason'] = reasons
                    trigger.clear()
                    next_start = now + 5
            if now - last_save >= 5:
                details['updated'] = timestamp()
                details['watched_directories'] = len(watch.paths)
                details['pending_reasons'] = sorted(trigger.reasons)
                tmp = STATE.with_suffix('.tmp')
                tmp.write_text(json.dumps(details, indent=2))
                tmp.replace(STATE)
                last_save = now
    finally:
        watch.close()

if __name__ == '__main__':
    main()
