#!/usr/bin/env python3
"""Studio sync agent. Python 3.10+, no external packages required."""
import argparse
import getpass
import hashlib
import json
import os
from pathlib import Path
import re
import stat
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

IGNORED = {'node_modules', 'vendor', '__pycache__', 'dist', 'build', 'venv'}
RESERVED = {'CON', 'PRN', 'AUX', 'NUL', *(f'COM{i}' for i in range(10)), *(f'LPT{i}' for i in range(10))}
LIMIT = 500000


def safe_name(name):
    if not isinstance(name, str) or len(name) > 180 or not re.fullmatch(r'[a-zA-Z0-9_./ -]+', name):
        return False
    return all(part and not part.startswith('.') and not part.endswith((' ', '.'))
               and part not in IGNORED and part.split('.')[0].upper() not in RESERVED for part in name.split('/'))


def digest(text):
    return hashlib.sha256(text.encode('utf-8')).hexdigest()


def checked_path(root, name):
    if not safe_name(name):
        raise ValueError('Onveilig of uitgesloten pad: ' + name)
    current = root
    for part in name.split('/'):
        current = current / part
        if current.is_symlink() or (current.exists() and getattr(current, 'is_junction', lambda: False)()):
            raise ValueError('Symlinks en junctions worden niet gesynchroniseerd: ' + name)
        if current.exists():
            mode = current.stat().st_mode
            if not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
                raise ValueError('Geen gewoon bestand: ' + name)
    current.resolve().relative_to(root)
    return current


def read_text(file):
    if file.stat().st_size > LIMIT:
        raise ValueError('Bestand groter dan 500 KB')
    text = file.read_bytes().decode('utf-8')
    if '\x00' in text:
        raise ValueError('Binair bestand')
    return text


def atomic_write(file, text):
    file.parent.mkdir(parents=True, exist_ok=True)
    # Exclusive creation prevents following an existing temporary symlink.
    temp = file.parent / ('.studio-write-' + os.urandom(12).hex())
    try:
        with temp.open('x', encoding='utf-8', newline='') as handle:
            handle.write(text)
        os.replace(temp, file)
    finally:
        temp.unlink(missing_ok=True)


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        raise ValueError('Serverredirect geweigerd. Gebruik het definitieve HTTPS-adres.')


class Client:
    def __init__(self, server, token=''):
        self.server = server.rstrip('/')
        self.token = token
        self.opener = urllib.request.build_opener(NoRedirect)

    def request(self, route, method='GET', body=None):
        headers = {'Content-Type': 'application/json'}
        if self.token:
            headers['Authorization'] = 'Bearer ' + self.token
        request = urllib.request.Request(self.server + route,
                                         data=json.dumps(body).encode() if body is not None else None,
                                         headers=headers, method=method)
        with self.opener.open(request, timeout=20) as response:
            return json.load(response)


def scan(root):
    result = {}
    for directory, dirs, files in os.walk(root, followlinks=False):
        dirs[:] = [d for d in dirs if safe_name(d) and not (Path(directory) / d).is_symlink()
                   and not getattr(Path(directory) / d, 'is_junction', lambda: False)()]
        for filename in files:
            file = Path(directory) / filename
            name = file.relative_to(root).as_posix()
            if not safe_name(name):
                continue
            try:
                text = read_text(checked_path(root, name))
                result[name] = text
            except (UnicodeError, ValueError, OSError):
                continue
            if len(result) > 250:
                raise ValueError('Meer dan 250 tekstbestanden. Kies een kleinere projectmap.')
    return result


def sync_once(client, project, root, meta, state):
    remote = client.request(f'/api/projects/{project}/files')
    local = scan(root)
    for name in sorted(set(local) | set(remote)):
        if not safe_name(name):
            continue
        target = checked_path(root, name)
        local_text = local.get(name)
        remote_file = remote.get(name)
        # Deletions are deliberately not propagated; restore from the surviving side.
        if remote_file is None:
            if local_text is None:
                continue
            try:
                client.request(f'/api/projects/{project}/file', 'PUT', {'name': name, 'content': local_text, 'base': ''})
            except urllib.error.HTTPError as error:
                if error.code == 409:
                    continue
                raise
            state[name] = digest(local_text)
            print('↑', name, flush=True)
            continue
        remote_text = remote_file['content']
        remote_hash = remote_file['hash']
        if not isinstance(remote_text, str) or len(remote_text.encode('utf-8')) > LIMIT or '\x00' in remote_text or digest(remote_text) != remote_hash:
            raise ValueError('Ongeldig serverbestand: ' + name)
        local_hash = digest(local_text) if local_text is not None else None
        base = state.get(name)
        if local_hash == remote_hash:
            state[name] = remote_hash
            continue
        if local_text is not None and base == remote_hash:
            try:
                client.request(f'/api/projects/{project}/file', 'PUT', {'name': name, 'content': local_text, 'base': remote_hash})
            except urllib.error.HTTPError as error:
                if error.code == 409:
                    continue
                raise
            state[name] = local_hash
            print('↑', name, flush=True)
            continue
        if local_text is not None and local_hash != base:
            backup = meta / 'conflicts' / (time.strftime('%Y%m%d-%H%M%S') + '-' + os.urandom(4).hex()) / name
            atomic_write(backup, local_text)
            print('CONFLICT: lokale versie bewaard in', backup, flush=True)
        # Recheck immediately before replacing to catch saves during the network request.
        checked_path(root, name)
        if target.exists():
            if local_text is None or read_text(target) != local_text:
                continue
        elif local_text is not None:
            continue
        atomic_write(target, remote_text)
        state[name] = remote_hash
        print('↓', name, flush=True)


def main():
    parser = argparse.ArgumentParser(description='Synchroniseer een Studio-project met een lokale map.')
    parser.add_argument('--server', required=True, help='HTTPS-adres van Studio')
    parser.add_argument('--folder', required=True, help='Lokale projectmap')
    parser.add_argument('--once', action='store_true', help='Eenmaal synchroniseren en stoppen')
    args = parser.parse_args()
    url = urllib.parse.urlsplit(args.server)
    if url.username or url.password or url.query or url.fragment or url.path not in ('', '/') or not url.hostname:
        parser.error('Gebruik alleen de oorsprong van de server, bijvoorbeeld https://studio.example.nl')
    if url.scheme != 'https' and not (url.scheme == 'http' and url.hostname in ('127.0.0.1', 'localhost')):
        parser.error('Gebruik HTTPS (HTTP is alleen toegestaan op localhost).')
    root = Path(args.folder).expanduser().resolve()
    root.mkdir(parents=True, exist_ok=True)
    meta = root / '.studio'
    if meta.is_symlink() or getattr(meta, 'is_junction', lambda: False)():
        parser.error('.studio mag geen symlink of junction zijn.')
    meta.mkdir(mode=0o700, exist_ok=True)
    # The private folder contains the credential, state and conflict copies.
    if os.name != 'nt':
        meta.chmod(0o700)
    for entry in meta.rglob('*'):
        if entry.is_symlink() or getattr(entry, 'is_junction', lambda: False)():
            parser.error('Onveilige link in .studio. Kies een andere map.')
    lock = meta / 'agent.lock'
    try:
        lock_fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
    except FileExistsError:
        parser.error('Er draait al een agent, of agent.lock is achtergebleven na een crash. Verwijder die alleen als de vorige agent gestopt is.')
    os.close(lock_fd)
    try:
        config_file = meta / 'config.json'
        state_file = meta / 'state.json'
        client = Client(args.server)
        if config_file.exists():
            config = json.loads(config_file.read_text())
            if config['server'] != client.server:
                parser.error('Deze map is al aan een andere server gekoppeld.')
        else:
            code = getpass.getpass('Koppelcode uit Studio: ').strip()
            config = client.request('/api/pair', 'POST', {'code': code})
            config['server'] = client.server
            atomic_write(config_file, json.dumps(config))
            if os.name != 'nt':
                config_file.chmod(0o600)
        client.token = config['token']
        state = json.loads(state_file.read_text()) if state_file.exists() else {}
        print('Verbonden met', config['name'], '→', root, flush=True)
        print('Tekstbestanden worden gesynchroniseerd. Verborgen bestanden, dependencies en binaire bestanden worden overgeslagen.', flush=True)
        while True:
            try:
                sync_once(client, config['project'], root, meta, state)
                atomic_write(state_file, json.dumps(state))
            except urllib.error.HTTPError as error:
                if error.code in (401, 403):
                    sys.exit('Toegang ingetrokken. Verwijder .studio/config.json en koppel opnieuw.')
                print('Serverfout:', error.code, flush=True)
                if args.once:
                    raise
            except (OSError, ValueError) as error:
                print('Synchronisatie onderbroken:', error, flush=True)
                if args.once:
                    raise
            if args.once:
                break
            time.sleep(2)
    finally:
        lock.unlink(missing_ok=True)


if __name__ == '__main__':
    try:
        main()
    except KeyboardInterrupt:
        print('\nAgent gestopt.')
