# SPDX-License-Identifier: AGPL-3.0-or-later
"""Configure Nextcloud."""

import os
import pathlib
import random
import re
import shutil
import string
import subprocess
import time

import augeas

from plinth import action_utils
from plinth.actions import privileged

NETWORK_NAME = 'nextcloud-fbx'
BRIDGE_IP = '172.16.16.1'
CONTAINER_IP = '172.16.16.2'
CONTAINER_NAME = 'nextcloud-fbx'
VOLUME_NAME = 'nextcloud-volume-fbx'
IMAGE_NAME = 'docker.io/library/nextcloud:stable-apache'

DB_HOST = 'localhost'
DB_NAME = 'nextcloud_fbx'
DB_USER = 'nextcloud_fbx'
GUI_ADMIN = 'nextcloud-admin'
SOCKET_CONFIG_FILE = pathlib.Path('/etc/mysql/mariadb.conf.d/'
                                  '99-freedombox.cnf')
SYSTEMD_LOCATION = '/etc/systemd/system/'
NEXTCLOUD_CONTAINER_SYSTEMD_FILE = pathlib.Path(
    f'{SYSTEMD_LOCATION}{CONTAINER_NAME}.service')
NEXTCLOUD_CRON_SERVICE_FILE = pathlib.Path(
    f'{SYSTEMD_LOCATION}nextcloud-cron-fbx.service')
NEXTCLOUD_CRON_TIMER_FILE = pathlib.Path(
    f'{SYSTEMD_LOCATION}nextcloud-cron-fbx.timer')

DB_BACKUP_FILE = pathlib.Path(
    '/var/lib/plinth/backups-data/nextcloud-database.sql')

REDIS_CONFIG = '/etc/redis/redis.conf'
REDIS_CONFIG_AUG = f'/files{REDIS_CONFIG}'


@privileged
def setup():
    """Setup Nextcloud configuration."""
    database_password = _generate_secret_key(16)
    administrator_password = _generate_secret_key(16)
    _configure_db_socket()
    _configure_firewall(action='add', interface_name=NETWORK_NAME)
    _create_database(database_password)
    action_utils.podman_run(
        network_name=NETWORK_NAME, subnet='172.16.16.0/24',
        bridge_ip=BRIDGE_IP, host_port='8181', container_port='80',
        container_ip=CONTAINER_IP, volume_name=VOLUME_NAME,
        container_name=CONTAINER_NAME, image_name=IMAGE_NAME,
        extra_run_options=[
            '--env=TRUSTED_PROXIES={BRIDGE_IP}',
            '--env=OVERWRITEWEBROOT=/nextcloud'
        ])
    # OCC isn't immediately available after the container is spun up.
    # Wait until CAN_INSTALL file is available.
    timeout = 300
    while timeout > 0:
        if os.path.exists('/var/lib/containers/storage/volumes/'
                          'nextcloud-volume-fbx/_data/config/CAN_INSTALL'):
            break
        timeout = timeout - 1
        time.sleep(1)

    _nextcloud_setup_wizard(database_password, administrator_password)
    _bind_redis(f'127.0.0.1 -::1 {BRIDGE_IP}')
    _set_redis_password(_generate_secret_key(16))
    action_utils.service_restart('redis-server')
    _create_redis_config(_get_redis_password())
    # Check if LDAP has already been configured. This is necessary because
    # if the setup proccess is rerun when updating the FredomBox app another
    # redundant LDAP config would be created.
    is_ldap_configured = _run_occ('ldap:test-config', 's01',
                                  capture_output=True)
    if is_ldap_configured != ('The configuration is valid and the connection '
                              'could be established!'):
        _configure_ldap()

    _configure_systemd()


def _run_occ(*args, capture_output: bool = False):
    """Run the Nextcloud occ command inside the container."""
    occ = [
        'podman', 'exec', '--user', 'www-data', CONTAINER_NAME, 'php', 'occ'
    ] + list(args)
    return subprocess.run(occ, capture_output=capture_output, check=False)


@privileged
def get_domain():
    """Return domain name set in Nextcloud."""
    try:
        domain = _run_occ('config:system:get', 'overwritehost',
                          capture_output=True)
        return domain.stdout.decode().strip()
    except subprocess.CalledProcessError:
        return None


@privileged
def set_domain(domain_name: str):
    """Set Nextcloud domain name."""
    protocol = 'https'
    if domain_name.endswith('.onion'):
        protocol = 'http'

    if domain_name:
        _run_occ('config:system:set', 'overwritehost', '--value', domain_name)

        _run_occ('config:system:set', 'overwrite.cli.url', '--value',
                 f'{protocol}://{domain_name}/nextcloud')

        _run_occ('config:system:set', 'overwriteprotocol', '--value', protocol)

        # Restart to apply changes immediately
        action_utils.service_restart('nextcloud-fbx')


@privileged
def set_admin_password(password: str):
    """Set password for owncloud-admin"""
    subprocess.run([
        'podman', 'exec', '--user', 'www-data', f'--env=OC_PASS={password}',
        '-it', CONTAINER_NAME, 'sh', '-c',
        ("/var/www/html/occ "
         f"user:resetpassword --password-from-env {GUI_ADMIN}")
    ], check=True)


@privileged
def get_default_phone_region():
    """"Get the value of default_phone_region."""
    try:
        default_phone_region = _run_occ('config:system:get',
                                        'default_phone_region',
                                        capture_output=True)
        return default_phone_region.stdout.decode().strip()
    except subprocess.CalledProcessError:
        return None


@privileged
def set_default_phone_region(region: str):
    """"Set the value of default_phone_region."""
    _run_occ('config:system:set', 'default_phone_region', '--value', region)


def _configure_firewall(action, interface_name):
    subprocess.run([
        'firewall-cmd', '--permanent', '--zone=trusted',
        f'--{action}-interface={interface_name}'
    ], check=True)
    action_utils.service_restart('firewalld')


def _configure_db_socket():
    file_content = f'''## This file is automatically generated by FreedomBox
## Enable database to create a socket for podman's bridge network
[mysqld]
bind-address            = {BRIDGE_IP}
'''
    SOCKET_CONFIG_FILE.write_text(file_content, encoding='utf-8')
    action_utils.service_restart('mariadb')


def _create_database(db_password):
    """Create an empty MySQL database for Nextcloud."""
    # SQL injection is avoided due to known input.
    _db_file_path = pathlib.Path('/var/lib/mysql/nextcloud_fbx')
    if _db_file_path.exists():
        return

    query = f'''CREATE DATABASE {DB_NAME} CHARACTER SET utf8mb4
  COLLATE utf8mb4_general_ci;
'''
    subprocess.run(['mysql', '--user', 'root'], input=query.encode(),
                   check=True)
    _set_db_privileges(db_password)


def _set_db_privileges(db_password):
    """Create user, set password and provide permissions on the database."""
    query = f'''GRANT ALL PRIVILEGES ON {DB_NAME}.* TO
  '{DB_USER}'@'{CONTAINER_IP}'
  IDENTIFIED BY'{db_password}';
FLUSH PRIVILEGES;
'''
    subprocess.run(['mysql', '--user', 'root'], input=query.encode(),
                   check=True)


def _nextcloud_setup_wizard(db_password, admin_password):
    admin_data_dir = pathlib.Path(
        '/var/lib/containers/storage/volumes/nextcloud-volume-fbx/'
        f'_data/data/{GUI_ADMIN}')
    if not admin_data_dir.exists():
        _run_occ('maintenance:install', '--database=mysql',
                 f'--database-name={DB_NAME}', f'--database-host={BRIDGE_IP}',
                 '--database-port=3306', f'--database-user={DB_USER}',
                 f'--database-pass={db_password}', f'--admin-user={GUI_ADMIN}',
                 f'--admin-pass={admin_password}')
    # For the server to work properly, it's important to configure background
    # jobs correctly. Cron is the recommended setting.
    _run_occ('background:cron')


def _configure_ldap():
    _run_occ('app:enable', 'user_ldap')
    _run_occ('ldap:create-empty-config')

    ldap_settings = {
        'ldapBase': 'dc=thisbox',
        'ldapBaseGroups': 'dc=thisbox',
        'ldapBaseUsers': 'dc=thisbox',
        'ldapConfigurationActive': '1',
        'ldapGroupDisplayName': 'cn',
        'ldapGroupFilter': '(&(|(objectclass=posixGroup)))',
        'ldapGroupFilterMode': '0',
        'ldapGroupFilterObjectclass': 'posixGroup',
        'ldapGroupMemberAssocAttr': 'memberUid',
        'ldapHost': BRIDGE_IP,
        'ldapLoginFilter': '(&(|(objectclass=posixAccount))(uid=%uid))',
        'ldapLoginFilterEmail': '0',
        'ldapLoginFilterMode': '0',
        'ldapLoginFilterUsername': '1',
        'ldapNestedGroups': '0',
        'ldapPort': '389',
        'ldapTLS': '0',
        'ldapUserDisplayName': 'cn',
        'ldapUserFilter': '(|(objectclass=posixAccount))',
        'ldapUserFilterMode': '0',
        'ldapUserFilterObjectclass': 'account',
        'ldapUuidGroupAttribute': 'auto',
        'ldapUuidUserAttribute': 'auto',
        'turnOffCertCheck': '0',
        'turnOnPasswordChange': '0',
        'useMemberOfToDetectMembership': '0'
    }

    for key, value in ldap_settings.items():
        _run_occ('ldap:set-config', 's01', key, value)


def _configure_systemd():
    systemd_content = subprocess.run(
        ['podman', 'generate', 'systemd', '--new', CONTAINER_NAME],
        capture_output=True, check=True).stdout.decode()
    # Create service and timer for running periodic php jobs.
    NEXTCLOUD_CONTAINER_SYSTEMD_FILE.write_text(systemd_content,
                                                encoding='utf-8')
    nextcloud_cron_service_content = '''
[Unit]
Description=Nextcloud cron.php job

[Service]
ExecCondition=/usr/bin/podman exec --user www-data nextcloud-fbx php occ status -e
ExecStart=/usr/bin/podman exec --user www-data nextcloud-fbx php /var/www/html/cron.php
KillMode=process
'''  # noqa: E501
    nextcloud_cron_timer_content = '''[Unit]
Description=Run Nextcloud cron.php every 5 minutes

[Timer]
OnBootSec=5min
OnUnitActiveSec=5min
Unit=nextcloud-cron-fbx.service

[Install]
WantedBy=timers.target
'''
    NEXTCLOUD_CRON_SERVICE_FILE.write_text(nextcloud_cron_service_content)
    NEXTCLOUD_CRON_TIMER_FILE.write_text(nextcloud_cron_timer_content)
    action_utils.service_daemon_reload()


@privileged
def uninstall():
    """Uninstall Nextcloud"""
    # Set bind setting back to default in case other apps
    # are still using it
    _bind_redis('127.0.0.1 -::1')
    action_utils.service_restart('redis-server')
    _drop_database()
    _remove_db_socket()
    _configure_firewall(action='remove', interface_name=NETWORK_NAME)
    action_utils.podman_uninstall(container_name=CONTAINER_NAME,
                                  network_name=NETWORK_NAME,
                                  volume_name=VOLUME_NAME,
                                  image_name=IMAGE_NAME)
    files = [NEXTCLOUD_CRON_SERVICE_FILE, NEXTCLOUD_CRON_TIMER_FILE]
    for file in files:
        file.unlink(missing_ok=True)


def _remove_db_socket():
    SOCKET_CONFIG_FILE.unlink(missing_ok=True)
    action_utils.service_restart('mariadb')


def _drop_database():
    """Drop the mysql database that was created during install."""
    with action_utils.service_ensure_running('mysql'):
        query = f'''DROP DATABASE {DB_NAME};
    DROP User '{DB_USER}'@'{CONTAINER_IP}';'''
        subprocess.run(['mysql', '--user', 'root'], input=query.encode(),
                       check=True)


def _generate_secret_key(length=64, chars=None):
    """Generate a new random secret key for use with Nextcloud."""
    chars = chars or (string.ascii_letters + string.digits)
    rand = random.SystemRandom()
    return ''.join(rand.choice(chars) for _ in range(length))


def _set_maintenance_mode(on: bool):
    """Turn maintenance mode on or off."""
    _run_occ('maintenance:mode', '--on' if on else '--off')


@privileged
def dump_database():
    """Dump database to file."""
    _set_maintenance_mode(True)
    DB_BACKUP_FILE.parent.mkdir(parents=True, exist_ok=True)
    with action_utils.service_ensure_running('mysql'):
        with DB_BACKUP_FILE.open('w', encoding='utf-8') as file_handle:
            subprocess.run([
                'mysqldump', '--add-drop-database', '--add-drop-table',
                '--add-drop-trigger', '--single-transaction',
                '--default-character-set=utf8mb4', '--user', 'root',
                '--databases', DB_NAME
            ], stdout=file_handle, check=True)
    _set_maintenance_mode(False)


@privileged
def restore_database():
    """Restore database from file."""
    with action_utils.service_ensure_running('mysql'):
        with DB_BACKUP_FILE.open('r', encoding='utf-8') as file_handle:
            subprocess.run(['mysql', '--user', 'root'], stdin=file_handle,
                           check=True)

        _set_db_privileges(_get_dbpassword())

    action_utils.service_restart('redis-server')
    _set_maintenance_mode(False)

    # Attempts to update UUIDs of user and group entries. By default,
    # the command attempts to update UUIDs that have been invalidated by
    # a migration step.
    _run_occ('ldap:update-uuid')

    # Update the systems data-fingerprint after a backup is restored
    _run_occ('maintenance:data-fingerprint')


def _get_dbpassword():
    """Return the database password from config.php.

    OCC cannot run unless Nextcloud can already connect to the database.
    """
    config_file = ('/var/lib/containers/storage/volumes/nextcloud-volume-fbx'
                   '/_data/config/config.php')
    with open(config_file, 'r', encoding='utf-8') as config:
        config_contents = config.read()

    pattern = r"'{}'\s*=>\s*'([^']*)'".format(re.escape('dbpassword'))
    match = re.search(pattern, config_contents)

    return match.group(1)


def _create_redis_config(password):
    """Create a php file for Redis configuration."""
    config_file = pathlib.Path(
        '/var/lib/containers/storage/volumes/nextcloud-volume-fbx/_data/'
        'config/freedombox.config.php')
    file_content = f'''<?php
$CONFIG = [
'filelocking.enabled' => true,
'memcache.locking' => '\\\\OC\\\\Memcache\\\\Redis',
'memcache.distributed' => '\\\\OC\\\\Memcache\\\\Redis',
'redis' => [
    'host' => '{BRIDGE_IP}',
    'port' => '6379',
    'password' => '{password}',
    ],
];
'''
    config_file.write_text(file_content)
    shutil.chown(config_file, 'www-data', 'www-data')


def _load_augeas():
    """Initialize Augeas."""
    aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
                        augeas.Augeas.NO_MODL_AUTOLOAD)
    aug.transform('Spacevars', REDIS_CONFIG)
    aug.set('/augeas/context', REDIS_CONFIG_AUG)
    aug.load()
    return aug


def _bind_redis(ip_address):
    """Configure Redis to listen on the podman bridge adapter."""
    aug = _load_augeas()
    aug.set(REDIS_CONFIG_AUG + '/bind', ip_address)
    aug.save()


def _set_redis_password(password):
    if _get_redis_password() is None:
        aug = _load_augeas()
        aug.set(REDIS_CONFIG_AUG + '/requirepass', password)
        aug.save()


def _get_redis_password() -> str:
    aug = _load_augeas()
    password = aug.get(REDIS_CONFIG_AUG + '/requirepass')
    return password
