#!/usr/bin/env python3
"""Repariert gezielt SkyZ-Laufzeitrechte. sudo python3 SkyZFiles-Rechte-Reparatur-20261011.py"""
import argparse
import datetime
import grp
import json
import os
from pathlib import Path
import pwd
import stat
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request

ROOT = Path('/data/skyzfiles')
SERVICES = ['skyzfiles-web', 'skyzfiles-worker']
PRIVATE = ['state', 'tmp', 'previews', 'logs']


def run(args, **kwargs):
    return subprocess.run(args, check=True, **kwargs)


def entries_for_tree(path, uid, gid, directory_mode, file_mode):
    if path.is_symlink() or not path.is_dir():
        raise RuntimeError('Kein regulärer Anwendungsordner: ' + str(path))
    result = []
    def add(p):
        info = p.lstat()
        if stat.S_ISLNK(info.st_mode):
            raise RuntimeError('Symlink gefunden; Rechte bitte gezielt prüfen: ' + str(p))
        if not (stat.S_ISDIR(info.st_mode) or stat.S_ISREG(info.st_mode)):
            raise RuntimeError('Unerwarteter Dateityp: ' + str(p))
        mode = directory_mode if stat.S_ISDIR(info.st_mode) else file_mode
        result.append({'path': str(p), 'uid': info.st_uid, 'gid': info.st_gid,
                       'mode': stat.S_IMODE(info.st_mode), 'new_uid': uid, 'new_gid': gid,
                       'new_mode': mode, 'inode': info.st_ino, 'device': info.st_dev})
    add(path)
    for directory, dirs, files in os.walk(path, followlinks=False):
        for name in dirs + files:
            add(Path(directory) / name)
    return result


def single_entry(path, uid, gid, mode, directory=False):
    info = path.lstat()
    if stat.S_ISLNK(info.st_mode) or not (stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)):
        raise RuntimeError('Unerwarteter Dateityp: ' + str(path))
    return {'path': str(path), 'uid': info.st_uid, 'gid': info.st_gid,
            'mode': stat.S_IMODE(info.st_mode), 'new_uid': uid, 'new_gid': gid,
            'new_mode': mode, 'inode': info.st_ino, 'device': info.st_dev}


def apply_entries(entries):
    for item in entries:
        p = Path(item['path'])
        info = p.lstat()
        if info.st_ino != item['inode'] or info.st_dev != item['device'] or stat.S_ISLNK(info.st_mode):
            raise RuntimeError('Datei wurde während der Prüfung geändert: ' + str(p))
        # Ownership first: chown can clear the setgid bit of a shared directory.
        if (info.st_uid, info.st_gid) != (item['new_uid'], item['new_gid']):
            os.chown(p, item['new_uid'], item['new_gid'], follow_symlinks=False)
        if stat.S_IMODE(p.lstat().st_mode) != item['new_mode']:
            os.chmod(p, item['new_mode'], follow_symlinks=False)


def service_property(service, name):
    return subprocess.check_output(['systemctl', 'show', service, '-p', name, '--value'], text=True).strip()


def verify_as_service(account, app, python):
    # Loading the actual WSGI handler exercises WhiteNoise; manage.py check alone does not.
    code = """import os
from config.wsgi import application
from django.db import connection
from django.conf import settings
c = connection.cursor()
c.execute('BEGIN IMMEDIATE')
c.execute('ROLLBACK')
c.close()
for name in ('state', 'tmp', 'objects', 'previews', 'logs'):
    p = settings.DATA_DIR / name
    fd, temp = __import__('tempfile').mkstemp(prefix='.skyz-permission-check-', dir=p)
    os.close(fd)
    os.unlink(temp)
for name in ('web.log', 'access.log'):
    with (settings.DATA_DIR / 'logs' / name).open('a'):
        pass
p = settings.DATA_DIR / 'sftp/files'
list(os.scandir(p))
fd, temp = __import__('tempfile').mkstemp(prefix='.skyz-permission-check-', dir=p)
os.close(fd)
os.unlink(temp)
print('OK: WSGI-Start, statische Dateien, SQLite-Schreibzugriff und Laufzeitordner.')
"""
    run(['runuser', '-u', account.pw_name, '--', str(python), str(app / 'manage.py'), 'shell', '-c', code], cwd=app)


def diagnostics():
    subprocess.run(['systemctl', 'status', *SERVICES, '--no-pager', '-l'], check=False)
    for name in ('web.log', 'worker.log'):
        path = ROOT / 'logs' / name
        if path.is_file():
            subprocess.run(['tail', '-n', '35', str(path)], check=False)


def wait_ready():
    for _ in range(16):
        active = all(subprocess.run(['systemctl', 'is-active', '--quiet', s]).returncode == 0 for s in SERVICES)
        if active:
            request = urllib.request.Request('http://127.0.0.1:8097/', headers={'Host': 'files.skyz.pet', 'X-Forwarded-Proto': 'https'})
            # A login redirect is healthy; do not follow it outside this local check.
            class NoRedirect(urllib.request.HTTPRedirectHandler):
                def redirect_request(self, *args):
                    return None
            try:
                with urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()).open(request, timeout=2) as response:
                    status = response.status
            except urllib.error.HTTPError as exc:
                status = exc.code
            except (urllib.error.URLError, OSError):
                status = 0
            if status in (200, 301, 302, 303, 307, 308):
                return status
        time.sleep(0.5)
    return 0


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--dry-run', action='store_true', help='Nur betroffene Ordner prüfen; nichts ändern.')
    args = parser.parse_args()
    if os.geteuid() != 0:
        raise RuntimeError('Bitte mit sudo python3 ausführen.')
    account = pwd.getpwnam('skyzfiles')
    group = grp.getgrnam('skyzfiles')
    sync_account = pwd.getpwnam('skyzsync')
    sync_group = grp.getgrgid(sync_account.pw_gid)
    app = ROOT / 'app'
    python = ROOT / 'venv/bin/python'
    if not (app / 'manage.py').is_file() or not python.is_file():
        raise RuntimeError('SkyZ-Installation unter /data/skyzfiles fehlt.')
    if not (ROOT / 'state/db.sqlite3').is_file():
        raise RuntimeError('Vorhandene Datenbank fehlt; keine neue Datenbank wird angelegt.')
    for service in SERVICES:
        if service_property(service, 'User') != account.pw_name or service_property(service, 'Group') not in ('', group.gr_name):
            raise RuntimeError('Abweichender Dienstbenutzer in ' + service + '; erst die Dienstkonfiguration prüfen.')
    entries = [single_entry(ROOT / 'config', 0, group.gr_gid, 0o750, directory=True),
               single_entry(ROOT / 'config/app.env', 0, group.gr_gid, 0o640)]
    for name in PRIVATE:
        entries.extend(entries_for_tree(ROOT / name, account.pw_uid, group.gr_gid, 0o750, 0o640))
    entries.extend(entries_for_tree(ROOT / 'objects', account.pw_uid, group.gr_gid, 0o750, 0o440))
    entries.extend(entries_for_tree(ROOT / 'static', account.pw_uid, group.gr_gid, 0o755, 0o644))
    entries.extend(entries_for_tree(ROOT / 'sftp/files', sync_account.pw_uid, sync_group.gr_gid, 0o2770, 0o660))
    changes = [e for e in entries if (e['uid'], e['gid'], e['mode']) != (e['new_uid'], e['new_gid'], e['new_mode'])]
    print(f'{len(changes)} Rechte-/Eigentümeränderungen in den SkyZ-Laufzeitordnern.', flush=True)
    print('Öffentliche Assets: 755/644; interne Daten: 750/640; Archivkopien: 440; gemeinsame SFTP-Dateien: 2770/660.', flush=True)
    if args.dry_run:
        return
    backup = ROOT / 'config' / ('rights-backup-' + datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f'))
    backup.mkdir(mode=0o700)
    snapshot = backup / 'permissions.json'
    snapshot.write_text(json.dumps(entries, ensure_ascii=False, indent=2))
    snapshot.chmod(0o600)
    print('Bisherige Eigentümer und Dateirechte gesichert:', backup, flush=True)
    run(['systemctl', 'stop', *SERVICES])
    try:
        apply_entries(entries)
        if sync_group.gr_gid not in os.getgrouplist(account.pw_name, account.pw_gid):
            run(['usermod', '-aG', sync_group.gr_name, account.pw_name])
        # Keep pre-existing logs; opening in append mode never truncates their contents.
        for name in ('web.log', 'access.log', 'worker.log'):
            p = ROOT / 'logs' / name
            if p.is_symlink():
                raise RuntimeError('Unerwarteter Log-Symlink: ' + str(p))
            with p.open('a'):
                pass
            os.chown(p, account.pw_uid, group.gr_gid)
            os.chmod(p, 0o640)
        verify_as_service(account, app, python)
        run(['systemctl', 'reset-failed', *SERVICES])
        run(['systemctl', 'start', *SERVICES])
        status = wait_ready()
        if not status:
            raise RuntimeError('Die Dienste starten noch nicht vollständig; aktuelle Diagnose folgt.')
        print(f'OK: WebApp lokal erreichbar (HTTP {status}); Web- und OCR-Dienst aktiv.', flush=True)
        subprocess.run(['systemctl', 'status', *SERVICES, '--no-pager', '-l'], check=False)
        print('files.skyz.pet jetzt neu laden. Die bereits konfigurierte Verarbeitung setzt sich nach dem Neustart fort.')
    except Exception:
        # Reverting broken ownership would recreate the confirmed failures.
        # Retain the repairs and collect the next concrete error instead.
        diagnostics()
        raise


if __name__ == '__main__':
    try:
        main()
    except (OSError, KeyError, RuntimeError, subprocess.CalledProcessError) as exc:
        print('Reparatur nicht vollständig abgeschlossen:', exc, file=sys.stderr)
        sys.exit(1)
