#!/usr/bin/env python3
"""Gemeinsames Team-Adressbuch fuer die vorhandene Allianz-Webmail-Installation.

Pruefen:     python3 roundcube-team-adressbuch.py --check
Einrichten:  python3 roundcube-team-adressbuch.py --apply
Entfernen:   python3 roundcube-team-adressbuch.py --remove
Anderer Installationspfad: zusaetzlich --install-dir /opt/allianz-webmail

Voraussetzungen: Python >= 3.8, root, Docker Compose v2, laufender Webmailer
aus dem Allianz-Webmail-Paket (Roundcube 1.7.1-apache). Kein pip erforderlich.

Unter Kontakte erscheint "Unheilige Allianz - Team" als zusaetzliches,
schreibgeschuetztes Adressbuch. Anzeigenamen werden auch bei der Suche im
An/CC/BCC-Feld angeboten. Sichtbar fuer ALLE angemeldeten Postfaecher der
Domain unheilige-allianz.de, einschliesslich info@ und zukuenftiger Konten.
Andere Domains und nicht angemeldete Besucher erhalten keinen Zugriff.

Es werden exakt die sieben unten angegebenen Kontakte bereitgestellt,
nicht automatisch weitere Mailcow-Adressen. Fuer andere Anzeigenamen unten
CONTACTS bearbeiten und --apply erneut ausfuehren. Es gibt keine Duplikate.
Keine echten Namen werden geraten; verwendet werden die angegebenen Handles.

Persoenliche Kontakte, Gruppen, Mails, IMAP-/SMTP-Rechte, Passwoerter,
Mailcow und TLS bleiben unveraendert. Keine Kontakte werden in die
Datenbank geschrieben. Keine Postfach-Anmeldung und keine Mail werden ausgeloest.
Nur der Webmail-Container wird kurz neu erstellt, mit seinem vorhandenen
Datenvolume und ohne Image-Download. Offene Entwuerfe vorher speichern.

Die Erweiterung ist kein CardDAV-Dienst: Sie erscheint in Roundcube,
nicht automatisch im Adressbuch auf iPhone/Android oder in SOGo.
Theme-Updates bleiben unabhaengig. Nach einer vollstaendigen Neuinstallation
mit install.sh kann --apply erneut notwendig sein (Compose wird dort ersetzt).

Das Skript sichert die betroffenen Dateien unter INSTALL_DIR/backups/ und
stellt sie bei Installationsfehlern wieder her. --remove entfernt nur diese
Erweiterung; zwischenzeitliche andere Compose-Einstellungen bleiben erhalten.
--check aendert keine Konfigurationsdateien. Rueckgabecode: 0 Erfolg, 1 Fehler/Abbruch.

Entwicklungspruefung: native Roundcube-1.7.1-Adressbuchschnittstellen,
PHP-Such-/Zugriffstests sowie Dateisystem-/Rollbacktests mit Docker-Doubles.
Keine Behauptung eines bereits erfolgten Tests auf dem produktiven Server.
Beim Ausfuehren werden PHP, Mounts, effektive Plugin-Konfiguration und lokale
Loginseite auf dem tatsaechlichen Server geprueft, ohne Postfach-Anmeldung.

Schnittstellen, eingesehen am 27.08.2026:
https://github.com/roundcube/roundcubemail/tree/1.7.1/plugins/example_addressbook
https://github.com/roundcube/roundcubemail/blob/1.7.1/program/lib/Roundcube/rcube_addressbook.php
https://github.com/roundcube/roundcubemail-docker/blob/master/apache/docker-entrypoint.sh
"""

import argparse
import copy
import datetime as dt
import hashlib
import http.client
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import time


DOMAIN = 'unheilige-allianz.de'
BOOK_NAME = 'Unheilige Allianz – Team'
# Anzeigename, E-Mail-Adresse. Neue Eintraege hier sind fuer die ganze Domain sichtbar.
CONTACTS = [
    ('Hoshizuki', 'hoshizuki@unheilige-allianz.de'),
    ('Kite', 'kite@unheilige-allianz.de'),
    ('Lexi', 'lexi@unheilige-allianz.de'),
    ('Raelia', 'raelia@unheilige-allianz.de'),
    ('Zuckerstift', 'zuckerstift@unheilige-allianz.de'),
    ('Infra', 'infra@unheilige-allianz.de'),
    ('Astralynia', 'astralynia@unheilige-allianz.de'),
]
PROJECT = 'allianz-webmail'
PLUGIN = 'allianz_team_contacts'
PLUGIN_DIR = Path('roundcube/plugins') / PLUGIN
PLUGIN_FILE = PLUGIN_DIR / (PLUGIN + '.php')
CONFIG_FILE = Path('roundcube/config/zz-allianz-team-contacts.inc.php')
COMPOSE_FILE = Path('compose.yaml')
MARKER = '<?php\n// Managed by roundcube-team-adressbuch.py v1\n'
MOUNTS = [
    './' + str(PLUGIN_DIR) + ':/var/www/html/plugins/' + PLUGIN + ':ro',
    './' + str(CONFIG_FILE) + ':/var/roundcube/config/' + CONFIG_FILE.name + ':ro',
]
TRACKED = (COMPOSE_FILE, PLUGIN_FILE, CONFIG_FILE)


class SetupError(Exception):
    pass


def php_string(value):
    return "'" + value.replace('\\', '\\\\').replace("'", "\\'") + "'"


def contact_records():
    if not re.fullmatch(r'[a-z0-9]+(?:[.-][a-z0-9]+)*\.[a-z]{2,}', DOMAIN):
        raise SetupError('DOMAIN im Skript ist ungueltig.')
    records, seen = [], set()
    for name, address in CONTACTS:
        if (not isinstance(name, str) or not name.strip() or len(name) > 100
                or any(ord(c) < 32 or ord(c) == 127 for c in name)):
            raise SetupError('Ein Anzeigename in CONTACTS ist ungueltig.')
        if (not isinstance(address, str)
                or not re.fullmatch(r'[a-z0-9][a-z0-9._+\-]*@' + re.escape(DOMAIN), address)
                or '..' in address.split('@')[0] or address.split('@')[0].endswith('.')
                or len(address.split('@')[0]) > 64 or len(address) > 254):
            raise SetupError('CONTACTS muss gueltige Adressen der freigegebenen Domain enthalten.')
        if address in seen:
            raise SetupError('Doppelte E-Mail-Adresse in CONTACTS: ' + address)
        seen.add(address)
        records.append({'ID': 'team-' + hashlib.sha256(address.encode()).hexdigest()[:20],
                        'name': name.strip(), 'firstname': name.strip(), 'surname': '',
                        'nickname': name.strip(), 'email': [address]})
    if not records:
        raise SetupError('CONTACTS darf nicht leer sein.')
    return records


PHP_PLUGIN = r'''
// No public endpoint or downloadable JSON with the directory data.
if (!defined('RCMAIL_VERSION') || !class_exists('rcube_plugin')) {
    http_response_code(404);
    exit;
}

class allianz_team_contacts extends rcube_plugin
{
    public $task = 'mail|addressbook|settings';
    public const BOOK_ID = 'allianz_team_contacts';
    public const DOMAIN = __TEAM_DOMAIN__;
    public const BOOK_NAME = __TEAM_NAME__;

    public function init()
    {
        $this->add_hook('addressbooks_list', [$this, 'address_sources']);
        $this->add_hook('addressbook_get', [$this, 'get_address_book']);
        $this->add_hook('startup', [$this, 'startup']);
        $this->startup([]);
    }

    // Only the authenticated account determines access, never an identity,
    // query parameter, contact email, or user-supplied domain preference.
    public static function permits($user_id, $username)
    {
        if ((int) $user_id <= 0 || !is_string($username)
            || !filter_var($username, FILTER_VALIDATE_EMAIL)) {
            return false;
        }
        $parts = explode('@', strtolower($username));
        return count($parts) === 2 && $parts[1] === self::DOMAIN;
    }

    private function allowed()
    {
        $rc = rcmail::get_instance();
        return !empty($rc->user->ID)
            && self::permits($rc->user->ID, $rc->user->get_username());
    }

    public function startup($args)
    {
        $config = rcmail::get_instance()->config;
        $sources = (array) $config->get('autocomplete_addressbooks', ['sql']);
        if ($this->allowed()) {
            if (!in_array(self::BOOK_ID, $sources, true)) {
                $sources[] = self::BOOK_ID;
            }
        } else {
            // Also clean a stale preference containing this source.
            $sources = array_values(array_filter($sources, static function ($id) {
                return $id !== self::BOOK_ID;
            }));
        }
        $config->set('autocomplete_addressbooks', $sources);
        return $args;
    }

    public function address_sources($args)
    {
        if ($this->allowed()) {
            $args['sources'][self::BOOK_ID] = [
                'id' => self::BOOK_ID, 'name' => self::BOOK_NAME,
                'readonly' => true, 'groups' => false, 'undelete' => false,
                'deletable' => false,
            ];
        } else {
            unset($args['sources'][self::BOOK_ID]);
        }
        return $args;
    }

    public function get_address_book($args)
    {
        if (($args['id'] ?? null) === self::BOOK_ID) {
            // Guessed source IDs never bypass the same access check.
            // An unauthorized request gets an empty, read-only source.
            $args['instance'] = new allianz_team_contacts_book($this->allowed());
        }
        return $args;
    }
}

class allianz_team_contacts_book extends rcube_addressbook
{
    public $primary_key = 'ID';
    public $readonly = true;
    public $groups = false;
    public $ready = true;
    public $coltypes = [
        'name' => ['limit' => 1], 'firstname' => ['limit' => 1],
        'surname' => ['limit' => 1], 'nickname' => ['limit' => 1],
        'email' => ['limit' => 1],
    ];
    private $records = [];
    private $filter = null;
    private $result = null;

    public function __construct($allowed)
    {
        if ($allowed === true) {
            $this->records = json_decode(__TEAM_RECORDS__, true, 512, JSON_THROW_ON_ERROR);
        }
    }

    public function get_name()
    {
        return allianz_team_contacts::BOOK_NAME;
    }

    public function set_search_set($filter): void
    {
        // Opaque filter = stable contact IDs, not SQL or executable expressions.
        $this->filter = $filter === null ? null : array_map('strval', (array) $filter);
        $this->result = null;
    }

    public function get_search_set()
    {
        return $this->filter;
    }

    public function reset(): void
    {
        $this->filter = null;
        $this->result = null;
    }

    private function rows()
    {
        $rows = array_values(array_filter($this->records, function ($row) {
            return $this->filter === null || in_array($row['ID'], $this->filter, true);
        }));
        $column = array_key_exists($this->sort_col, $this->coltypes) ? $this->sort_col : 'name';
        $direction = $this->sort_order === 'DESC' ? -1 : 1;
        usort($rows, static function ($a, $b) use ($column, $direction) {
            $left = mb_strtolower(implode(' ', (array) $a[$column]), 'UTF-8');
            $right = mb_strtolower(implode(' ', (array) $b[$column]), 'UTF-8');
            $order = strcmp($left, $right) ?: strcmp($a['ID'], $b['ID']);
            return $direction * $order;
        });
        return $rows;
    }

    private function offset()
    {
        return (max(1, (int) $this->list_page) - 1) * max(1, (int) $this->page_size);
    }

    public function list_records($cols = null, $subset = 0, $nocount = false)
    {
        $all = $this->rows();
        $first = $this->offset();
        $rows = array_slice($all, $first, max(1, (int) $this->page_size));
        if ($subset > 0) {
            $rows = array_slice($rows, 0, $subset);
        } elseif ($subset < 0) {
            $rows = array_slice($rows, $subset);
        }
        $this->result = new rcube_result_set($nocount ? count($rows) : count($all), $first);
        foreach ($rows as $row) {
            if ($cols !== null && !in_array('*', (array) $cols, true)) {
                $row = array_intersect_key($row, array_flip(array_merge(['ID'], (array) $cols)));
            }
            $this->result->add($row);
        }
        return $this->result;
    }

    private function matches($row, $field, $value, $mode)
    {
        if (!is_string($field) || !array_key_exists($field, $this->coltypes)
            || !is_scalar($value)) {
            return false;
        }
        $wanted = mb_strtolower((string) $value, 'UTF-8');
        foreach ((array) ($row[$field] ?? []) as $item) {
            $item = mb_strtolower((string) $item, 'UTF-8');
            if ($mode & self::SEARCH_STRICT) {
                $match = $item === $wanted;
            } elseif ($mode & self::SEARCH_PREFIX) {
                $match = strncmp($item, $wanted, strlen($wanted)) === 0;
            } else {
                $match = strpos($item, $wanted) !== false;
            }
            if ($match) {
                return true;
            }
        }
        return false;
    }

    public function search($fields, $value, $mode = 0, $select = true, $nocount = false, $required = [])
    {
        $ids = [];
        $by_id = $fields === 'ID' || $fields === ['ID'];
        $id_values = $by_id ? array_map('strval', is_array($value) ? $value : explode(',', (string) $value)) : [];
        $field_list = $fields === '*' ? array_keys($this->coltypes) : (array) $fields;
        foreach ($this->records as $row) {
            $complete = true;
            foreach ((array) $required as $field) {
                if (!is_string($field) || !array_filter((array) ($row[$field] ?? []), static function ($v) {
                    return trim((string) $v) !== '';
                })) {
                    $complete = false;
                    break;
                }
            }
            if (!$complete) {
                continue;
            }
            if ($by_id) {
                $found = in_array($row['ID'], $id_values, true);
            } elseif (is_array($value)) {
                // Advanced search: all field/value pairs must match.
                $found = count($field_list) > 0 && count($field_list) === count($value);
                foreach (array_values($field_list) as $i => $field) {
                    if (!$this->matches($row, $field, array_values($value)[$i] ?? null, $mode)) {
                        $found = false;
                        break;
                    }
                }
            } else {
                // Ordinary search: match any requested field.
                $found = false;
                foreach ($field_list as $field) {
                    if ($this->matches($row, $field, $value, $mode)) {
                        $found = true;
                        break;
                    }
                }
            }
            if ($found) {
                $ids[] = $row['ID'];
            }
        }
        $this->set_search_set($ids);
        if ($select) {
            return $this->list_records(null, 0, $nocount);
        }
        return $this->result = new rcube_result_set($nocount ? 0 : count($ids), $this->offset());
    }

    public function count()
    {
        return $this->result = new rcube_result_set(count($this->rows()), $this->offset());
    }

    public function get_result()
    {
        return $this->result;
    }

    public function get_record($id, $assoc = false)
    {
        $this->result = new rcube_result_set();
        foreach ($this->records as $row) {
            if ($row['ID'] === (string) $id) {
                $this->result->add($row);
                $this->result->count = 1;
                return $assoc ? $row : $this->result;
            }
        }
        return $assoc ? null : $this->result;
    }
    // All mutating methods are inherited from the read-only base class and
    // return false/zero. No SQL handle, credentials or writable data store.
}
'''


def payload():
    records = contact_records()
    code = PHP_PLUGIN.replace('__TEAM_DOMAIN__', php_string(DOMAIN))
    code = code.replace('__TEAM_NAME__', php_string(BOOK_NAME))
    code = code.replace('__TEAM_RECORDS__', php_string(json.dumps(records, ensure_ascii=False)))
    config = (MARKER + '// Loaded after allianz.inc.php by the official Roundcube Docker image.\n'
              "$config['plugins'] = array_values(array_unique(array_merge(\n"
              "    (array) ($config['plugins'] ?? []), ['allianz_team_contacts']\n"
              ")));\n")
    return {PLUGIN_FILE: (MARKER + code).encode('utf-8'), CONFIG_FILE: config.encode('utf-8')}


def safe_path(path):
    for candidate in [path, *path.parents]:
        if candidate.is_symlink():
            raise SetupError('Symbolischer Link im Zielpfad; nichts ueberschrieben: ' + str(candidate))


def checked_file(path):
    safe_path(path)
    if not path.is_file():
        raise SetupError('Erwartete Datei fehlt: ' + str(path))
    return path.read_bytes()


def mount_target(mount):
    if isinstance(mount, str):
        parts = mount.split(':')
        if len(parts) >= 2:
            return parts[1]
    elif isinstance(mount, dict):
        return mount.get('target')
    return None


def read_installation(target):
    safe_path(target)
    for rel in (*TRACKED, PLUGIN_DIR, Path('backups'), Path('state/installation.json')):
        safe_path(target / rel)
    try:
        installation = json.loads(checked_file(target / 'state/installation.json'))
        document = json.loads(checked_file(target / COMPOSE_FILE))
    except (ValueError, UnicodeError) as exc:
        raise SetupError('Installationsdaten oder compose.yaml sind nicht im erwarteten JSON-Format des Allianz-Pakets. Keine automatische Aenderung.') from exc
    if (not isinstance(installation, dict) or installation.get('project') != PROJECT
            or not isinstance(document, dict) or document.get('name') != PROJECT
            or not isinstance(document.get('services'), dict)
            or set(document['services']) != {'webmail'}):
        raise SetupError('Das Ziel ist keine isolierte Allianz-Webmail-Installation. Keine Aenderung.')
    host = installation.get('webmail_host', '')
    port = installation.get('port')
    if (not isinstance(host, str) or len(host) > 253
            or not re.fullmatch(r'[A-Za-z0-9]+(?:[.-][A-Za-z0-9]+)*\.[A-Za-z]{2,}', host)
            or type(port) is not int or not 1 <= port <= 65535):
        raise SetupError('Webmail-Hostname oder Port im Installationsstatus ungueltig.')
    for name in ('compose.override.yaml', 'compose.override.yml', 'docker-compose.override.yaml', 'docker-compose.override.yml'):
        if (target / name).exists() or (target / name).is_symlink():
            raise SetupError('Zusaetzliche Compose-Override-Datei gefunden. Bitte zuerst individuell pruefen: ' + name)
    if os.environ.get('COMPOSE_FILE') or (os.environ.get('COMPOSE_PROJECT_NAME') not in (None, '', PROJECT)):
        raise SetupError('Abweichende COMPOSE_FILE/COMPOSE_PROJECT_NAME-Umgebung; bitte in einer normalen Root-Shell starten.')
    for rel in (Path('roundcube/config/allianz.inc.php'), Path('roundcube/plugins/allianz_webmail/allianz_webmail.php')):
        checked_file(target / rel)
    service = document['services']['webmail']
    if (not isinstance(service, dict) or not isinstance(service.get('image'), str)
            or not service['image'].startswith('roundcube/roundcubemail:')
            or not isinstance(service.get('volumes'), list)
            or 'webmail-db:/var/roundcube/db' not in service['volumes']
            or document.get('volumes', {}).get('webmail-db', {}).get('name') != 'allianz-webmail-db'):
        raise SetupError('Unerwartetes Webmail-Image oder Datenvolume. Keine Aenderung.')
    for rel in (PLUGIN_FILE, CONFIG_FILE):
        path = target / rel
        if path.exists() and (not path.is_file() or not path.read_bytes().startswith(MARKER.encode())):
            raise SetupError('Gleichnamige, nicht von diesem Skript verwaltete Datei: ' + str(path))
    if (target / PLUGIN_DIR).exists():
        if not (target / PLUGIN_DIR).is_dir() or any(p.name != PLUGIN_FILE.name for p in (target / PLUGIN_DIR).iterdir()):
            raise SetupError('Unerwarteter Inhalt im Erweiterungsverzeichnis. Keine Dateien entfernt.')
    for expected in MOUNTS:
        conflicts = [v for v in service['volumes'] if mount_target(v) == mount_target(expected)]
        if conflicts and conflicts != [expected]:
            raise SetupError('Abweichender oder doppelter Mount fuer das Team-Adressbuch. Keine Aenderung.')
    return installation, document


def run(arguments, input_bytes=None, timeout=60):
    try:
        result = subprocess.run(arguments, input=input_bytes, stdout=subprocess.PIPE,
                                stderr=subprocess.PIPE, timeout=timeout, check=False)
    except (OSError, subprocess.TimeoutExpired) as exc:
        raise SetupError('Docker/PHP-Aufruf nicht abgeschlossen. Laufenden Webmail-Container pruefen.') from exc
    if result.returncode:
        # Compose/config output may contain secrets. Do not dump it.
        raise SetupError('Docker/PHP-Pruefung fehlgeschlagen (Exit ' + str(result.returncode) + '). Keine Zugangsdaten werden ausgegeben.')
    return result.stdout


def compose(target, *args, input_bytes=None, timeout=60, stdin_config=False):
    return run(['docker', 'compose', '--project-directory', str(target), '--project-name', PROJECT,
                '--file', '-' if stdin_config else str(target / COMPOSE_FILE), *args],
               input_bytes, timeout)


def preflight(target, document, new_compose, files):
    compose(target, 'config', '--quiet')
    compose(target, 'config', '--quiet', input_bytes=new_compose, stdin_config=True)
    ids = compose(target, 'ps', '--status', 'running', '-q', 'webmail').decode().split()
    if len(ids) != 1 or not re.fullmatch(r'[0-9a-f]{12,64}', ids[0]):
        raise SetupError('Genau ein laufender Webmail-Container wird benoetigt.')
    try:
        live = json.loads(run(['docker', 'inspect', ids[0]]))[0]
        labels = live['Config'].get('Labels') or {}
        available_image = json.loads(run(['docker', 'image', 'inspect', document['services']['webmail']['image']]))[0]['Id']
    except (ValueError, KeyError, IndexError, TypeError) as exc:
        raise SetupError('Container/Image nicht eindeutig pruefbar.') from exc
    if labels.get('com.docker.compose.project') != PROJECT or labels.get('com.docker.compose.service') != 'webmail':
        raise SetupError('Laufender Container gehoert zu einem anderen Projekt.')
    if live.get('Image') != available_image:
        raise SetupError('Der lokale Image-Tag unterscheidet sich vom laufenden Image. Ein unbeabsichtigtes Roundcube-Upgrade wird verhindert.')
    db = [m for m in live.get('Mounts', []) if m.get('Destination') == '/var/roundcube/db']
    if len(db) != 1 or db[0].get('Type') != 'volume' or db[0].get('Name') != 'allianz-webmail-db':
        raise SetupError('Das laufende Datenvolume stimmt nicht mit der Installation ueberein.')
    for rel, code in files.items():
        try:
            compose(target, 'exec', '-T', '--user', 'www-data', 'webmail', 'php', '-l', input_bytes=code, timeout=30)
        except SetupError as exc:
            raise SetupError('PHP-Syntaxpruefung fehlgeschlagen: ' + str(rel) + '. Keine Dateien veraendert.') from exc


def snapshot(target):
    result = {}
    for rel in TRACKED:
        path = target / rel
        if path.exists():
            metadata = path.stat()
            result[rel] = (path.read_bytes(), stat.S_IMODE(metadata.st_mode), metadata.st_uid, metadata.st_gid)
        else:
            result[rel] = None
    return result


def atomic_write(path, data, mode=0o644, uid=None, gid=None):
    safe_path(path)
    path.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
    fd, name = tempfile.mkstemp(prefix='.' + path.name + '.', dir=str(path.parent))
    try:
        with os.fdopen(fd, 'wb') as stream:
            stream.write(data)
            stream.flush()
            os.fsync(stream.fileno())
            os.fchmod(stream.fileno(), mode)
            if uid is not None and gid is not None:
                os.fchown(stream.fileno(), uid, gid)
        os.replace(name, path)
    finally:
        if os.path.exists(name):
            os.unlink(name)


def backup_files(target, originals):
    stamp = dt.datetime.now(dt.timezone.utc).strftime('%Y%m%d-%H%M%S-%f')
    directory = target / 'backups' / ('team-addressbook-' + stamp)
    safe_path(directory)
    directory.mkdir(mode=0o700, parents=True)
    metadata = {}
    for rel, old in originals.items():
        metadata[str(rel)] = None if old is None else {'mode': old[1], 'uid': old[2], 'gid': old[3]}
        if old is not None:
            atomic_write(directory / rel, old[0], 0o600)
    atomic_write(directory / 'original-state.json', json.dumps(metadata, indent=2).encode() + b'\n', 0o600)
    return directory


def restore(target, originals):
    for rel, old in originals.items():
        if old is None:
            (target / rel).unlink(missing_ok=True)
        else:
            atomic_write(target / rel, *old)
    plugin_dir = target / PLUGIN_DIR
    if plugin_dir.is_dir() and not any(plugin_dir.iterdir()):
        plugin_dir.rmdir()


def health_once(site):
    connection = http.client.HTTPConnection('127.0.0.1', site['port'], timeout=4)
    try:
        connection.request('GET', '/?_task=login', headers={
            'Host': site['webmail_host'], 'X-Forwarded-Proto': 'https',
            'Cache-Control': 'no-cache',
        })
        response = connection.getresponse()
        body = response.read(1_000_000)
        return response.status == 200 and b'login-form' in body
    except (OSError, http.client.HTTPException):
        return False
    finally:
        connection.close()


def wait_health(site):
    deadline = time.monotonic() + 45
    while True:
        if health_once(site):
            return
        if time.monotonic() >= deadline:
            raise SetupError('Die lokale Loginseite ist nach dem Neustart noch nicht bereit.')
        time.sleep(1.5)


def runtime_check(target, files, enabled):
    # Load configuration and addressbook classes only. Never start a Roundcube
    # session, authenticate a mailbox or query/update users/contacts tables.
    checks = "<?php\nrequire '/var/www/html/program/include/iniset.php';\n"
    checks += "$cfg = new rcube_config();\n"
    checks += "$loaded = in_array('allianz_team_contacts', (array) $cfg->get('plugins'), true);\n"
    checks += 'if ($loaded !== ' + ('true' if enabled else 'false') + ') { exit(11); }\n'
    if enabled:
        for rel, code in files.items():
            remote = ('/var/www/html/plugins/' + PLUGIN + '/' + PLUGIN_FILE.name
                      if rel == PLUGIN_FILE else '/var/roundcube/config/' + CONFIG_FILE.name)
            checks += 'if (@hash_file(\'sha256\', ' + php_string(remote) + ') !== ' + php_string(hashlib.sha256(code).hexdigest()) + ') { exit(12); }\n'
        checks += "require '/var/www/html/plugins/allianz_team_contacts/allianz_team_contacts.php';\n"
        checks += "$book = new allianz_team_contacts_book(true);\n"
        checks += 'if (!$book->readonly || $book->count()->count !== ' + str(len(contact_records())) + ') { exit(13); }\n'
        for rec in contact_records():
            checks += '$r = $book->search(\'email\', ' + php_string(rec['email'][0]) + ', 1)->first();\n'
            checks += 'if (!$r || $r[\'name\'] !== ' + php_string(rec['name']) + ') { exit(14); }\n'
        checks += "$denied = new allianz_team_contacts_book(false);\n"
        checks += "if ($denied->count()->count !== 0 || $book->insert([]) !== false || $book->delete('x') !== false) { exit(15); }\n"
        checks += 'if (!allianz_team_contacts::permits(1, ' + php_string(CONTACTS[0][1]) + ')) { exit(16); }\n'
        checks += 'if (allianz_team_contacts::permits(0, ' + php_string(CONTACTS[0][1]) + ') || allianz_team_contacts::permits(1, \'other@example.invalid\')) { exit(17); }\n'
    checks += 'echo "ALLIANZ_TEAM_CHECK_OK\\n";\n'
    result = compose(target, 'exec', '-T', '--user', 'www-data', 'webmail', 'php', input_bytes=checks.encode(), timeout=30)
    if result.strip() != b'ALLIANZ_TEAM_CHECK_OK':
        raise SetupError('Die effektive Roundcube-Konfiguration oder Erweiterung konnte nicht bestaetigt werden.')


def recreate(target):
    compose(target, 'up', '-d', '--no-deps', '--force-recreate', '--pull', 'never', 'webmail', timeout=120)


def execute(args):
    if sys.version_info < (3, 8):
        raise SetupError('Python 3.8 oder neuer wird benoetigt.')
    if os.geteuid() != 0:
        raise SetupError('Bitte als root oder mit sudo ausfuehren.')
    if shutil.which('docker') is None:
        raise SetupError('Docker wurde nicht gefunden.')
    target = Path(os.path.abspath(args.install_dir))
    site, document = read_installation(target)
    removing = args.remove
    # Undo must also work if CONTACTS was edited incorrectly after installation.
    files = {PLUGIN_FILE: b'', CONFIG_FILE: b''} if removing else payload()
    updated = copy.deepcopy(document)
    mounts = updated['services']['webmail']['volumes']
    for mount in MOUNTS:
        if removing:
            if mount in mounts:
                mounts.remove(mount)
        elif mount not in mounts:
            mounts.append(mount)
    originals = snapshot(target)
    new_compose = (originals[COMPOSE_FILE][0] if updated == document
                   else (json.dumps(updated, ensure_ascii=False, indent=2) + '\n').encode())
    expected = {COMPOSE_FILE: new_compose, **({rel: None for rel in files} if removing else files)}
    changed = [rel for rel, data in expected.items() if (originals[rel][0] if originals[rel] else None) != data]
    if removing and not changed:
        print('Das Team-Adressbuch ist hier nicht eingerichtet. Keine Dateien veraendert.')
        return
    preflight(target, document, new_compose, {} if removing else files)
    if not removing and not health_once(site):
        raise SetupError('Die vorhandene lokale Loginseite ist nicht bereit. Erst den Webmailer pruefen; keine Dateien veraendert.')
    print('Ziel: ' + site['webmail_host'] + ' — Adressbuch: ' + BOOK_NAME, flush=True)
    print('Sichtbar nur fuer angemeldete Konten von ' + DOMAIN + '.', flush=True)
    if args.check:
        for record in contact_records():
            print('  ' + record['name'] + ' <' + record['email'][0] + '>')
        if not changed:
            runtime_check(target, files, True)
            print('Bereits eingerichtet; effektive Konfiguration und Adressbuch erfolgreich geprueft.')
        else:
            print(str(len(changed)) + ' Dateien wuerden ergaenzt/angepasst. PHP und Compose erfolgreich geprueft.')
        print('Nur geprueft; keine Dateien veraendert und kein Container neu gestartet.')
        return
    if not changed and not removing:
        try:
            runtime_check(target, files, True)
        except SetupError:
            print('Dateien vorhanden, Aktivierung noch nicht bestaetigt; Webmailer wird neu erstellt.', flush=True)
        else:
            print('Adressbuch unveraendert und erfolgreich geprueft; kein Neustart erforderlich.')
            return
    backup = backup_files(target, originals)
    print('Sicherung: ' + str(backup), flush=True)
    recreated = False
    try:
        # On removal keep old plugin files available until the replacement
        # container is running without their mounts.
        for rel, data in expected.items():
            if data is not None and rel in changed:
                old = originals[rel]
                if old:
                    atomic_write(target / rel, data, *old[1:])
                else:
                    atomic_write(target / rel, data)
        compose(target, 'config', '--quiet')
        print('Nur der Webmailer wird kurz neu erstellt. Mailcow bleibt in Betrieb.', flush=True)
        recreated = True
        recreate(target)
        wait_health(site)
        runtime_check(target, files, not removing)
        if removing:
            for rel in files:
                (target / rel).unlink(missing_ok=True)
            directory = target / PLUGIN_DIR
            if directory.is_dir() and not any(directory.iterdir()):
                directory.rmdir()
    except (Exception, KeyboardInterrupt) as exc:
        try:
            restore(target, originals)
            if recreated:
                recreate(target)
                wait_health(site)
        except (Exception, KeyboardInterrupt) as rollback_error:
            raise SetupError('Vorgang und Wiederherstellung konnten nicht vollstaendig bestaetigt werden. Sicherung: ' + str(backup)) from rollback_error
        raise SetupError('Vorgang fehlgeschlagen; vorherige Dateien wiederhergestellt' +
                         (' und lokale Loginseite geprueft' if recreated else '') +
                         '. Sicherung: ' + str(backup) + '. Ursache: ' + str(exc)) from exc
    if removing:
        print('Team-Adressbuch entfernt; lokale Loginseite und deaktivierte Erweiterung geprueft.')
    else:
        print(str(len(contact_records())) + ' Team-Kontakte eingerichtet. PHP, Suchlogik, Mounts, Plugin-Konfiguration und lokale Loginseite geprueft.')
        print('Im Browser neu laden. Unter Kontakte → ' + BOOK_NAME + ' nachsehen; beim Schreiben z. B. Hoshizuki suchen.')
    print('Persoenliche Kontakte, Mailcow und Postfachfreigaben wurden nicht geaendert.')


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    action = parser.add_mutually_exclusive_group(required=True)
    action.add_argument('--check', action='store_true', help='Pruefen und Vorschau anzeigen, nichts aendern')
    action.add_argument('--apply', action='store_true', help='Adressbuch einrichten/aktualisieren')
    action.add_argument('--remove', action='store_true', help='Nur diese Erweiterung entfernen')
    parser.add_argument('--install-dir', default='/opt/allianz-webmail', help='Vorhandene Webmail-Installation')
    args = parser.parse_args(argv)
    try:
        execute(args)
    except (SetupError, OSError, ValueError, KeyboardInterrupt) as exc:
        print('Abgebrochen: ' + str(exc), file=sys.stderr)
        return 1
    return 0


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