# SPDX-License-Identifier: AGPL-3.0-or-later
"""
FreedomBox app to manage backup archives.
"""

import json
import logging
import os
import pathlib
import re

import paramiko
from django.utils.text import get_valid_filename
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_noop

from plinth import actions
from plinth import app as app_module
from plinth import cfg, glib, menu
from plinth.package import Packages

from . import api

logger = logging.getLogger(__name__)

version = 3

is_essential = True

managed_packages = ['borgbackup', 'sshfs']

depends = ['storage']

_description = [
    _('Backups allows creating and managing backup archives.'),
]

MANIFESTS_FOLDER = '/var/lib/plinth/backups-manifests/'
# session variable name that stores when a backup file should be deleted
SESSION_PATH_VARIABLE = 'fbx-backups-upload-path'

app = None


class BackupsApp(app_module.App):
    """FreedomBox app for backup and restore."""

    app_id = 'backups'

    def __init__(self):
        """Create components for the app."""
        super().__init__()

        info = app_module.Info(
            app_id=self.app_id, version=version, depends=depends,
            name=_('Backups'), icon='fa-files-o', description=_description,
            manual_page='Backups',
            donation_url='https://www.borgbackup.org/support/fund.html')
        self.add(info)

        menu_item = menu.Menu('menu-backups', info.name, None, info.icon,
                              'backups:index', parent_url_name='system')
        self.add(menu_item)

        packages = Packages('packages-backups', managed_packages)
        self.add(packages)

    @staticmethod
    def post_init():
        """Perform post initialization operations."""
        # Check every hour (every 3 minutes in debug mode) to perform scheduled
        # backups.
        interval = 180 if cfg.develop else 3600
        glib.schedule(interval, backup_by_schedule)


def setup(helper, old_version=None):
    """Install and configure the module."""
    helper.install(managed_packages)
    from . import repository
    helper.call('post', actions.superuser_run, 'backups',
                ['setup', '--path', repository.RootBorgRepository.PATH])
    helper.call('post', app.enable)

    # First time setup or upgrading from older versions.
    if old_version <= 2:
        _show_schedule_setup_notification()


def _backup_handler(packet, encryption_passphrase=None):
    """Performs backup operation on packet."""
    if not os.path.exists(MANIFESTS_FOLDER):
        os.makedirs(MANIFESTS_FOLDER)

    manifest_path = os.path.join(MANIFESTS_FOLDER,
                                 get_valid_filename(packet.path) + '.json')
    manifests = {
        'apps': [{
            'name': component.app.app_id,
            'version': component.app.info.version,
            'backup': component.manifest
        } for component in packet.components]
    }
    with open(manifest_path, 'w') as manifest_file:
        json.dump(manifests, manifest_file)

    paths = packet.directories + packet.files
    paths.append(manifest_path)
    arguments = ['create-archive', '--path', packet.path]
    if packet.archive_comment:
        arguments += ['--comment', packet.archive_comment]

    arguments += ['--paths'] + paths
    input_data = ''
    if encryption_passphrase:
        input_data = json.dumps(
            {'encryption_passphrase': encryption_passphrase})

    actions.superuser_run('backups', arguments, input=input_data.encode())


def backup_by_schedule(data):
    """Check if backups need to be taken and run the operation."""
    from . import repository as repository_module
    for repository in repository_module.get_repositories():
        try:
            repository.schedule.run_schedule()
            _show_schedule_error_notification(repository, is_error=False)
        except Exception as exception:
            logger.exception('Error running scheduled backup: %s', exception)
            _show_schedule_error_notification(repository, is_error=True,
                                              exception=exception)


def get_exported_archive_apps(path):
    """Get list of apps included in exported archive file."""
    arguments = ['get-exported-archive-apps', '--path', path]
    output = actions.superuser_run('backups', arguments)
    return output.splitlines()


def _restore_exported_archive_handler(packet, encryption_passphrase=None):
    """Perform restore operation on packet."""
    locations = {'directories': packet.directories, 'files': packet.files}
    locations_data = json.dumps(locations)
    actions.superuser_run('backups',
                          ['restore-exported-archive', '--path', packet.path],
                          input=locations_data.encode())


def restore_archive_handler(packet, encryption_passphrase=None):
    """Perform restore operation on packet."""
    locations = {
        'directories': packet.directories,
        'files': packet.files,
        'encryption_passphrase': encryption_passphrase
    }
    locations_data = json.dumps(locations)
    arguments = [
        'restore-archive', '--path', packet.path, '--destination', '/'
    ]
    actions.superuser_run('backups', arguments, input=locations_data.encode())


def restore_from_upload(path, app_ids=None):
    """Restore files from an uploaded .tar.gz backup file"""
    api.restore_apps(_restore_exported_archive_handler, app_ids=app_ids,
                     create_subvolume=False, backup_file=path)


def get_known_hosts_path():
    """Return the path to the known hosts file."""
    return pathlib.Path(cfg.data_dir) / '.ssh' / 'known_hosts'


def is_ssh_hostkey_verified(hostname):
    """Check whether SSH Hostkey has already been verified.

    hostname: Domain name or IP address of the host

    """
    known_hosts_path = get_known_hosts_path()
    if not known_hosts_path.exists():
        return False

    known_hosts = paramiko.hostkeys.HostKeys(str(known_hosts_path))
    host_keys = known_hosts.lookup(hostname)
    return host_keys is not None


def split_path(path):
    """Splits the given path into username, hostname, directory.

    Network interface information is kept in the hostname if provided.
    e.g. fe80::2078:6c26:498a:1fa5%wlp1s0

    """
    return re.findall(r'^(.*)@([^/]*):(.*)$', path)[0]


def _show_schedule_setup_notification():
    """Show a notification hinting to setup a remote backup schedule."""
    from plinth.notification import Notification
    message = gettext_noop(
        'Enable an automatic backup schedule for data safety. Prefer an '
        'encrypted remote backup location or an extra attached disk.')
    data = {
        'app_name': 'translate:' + gettext_noop('Backups'),
        'app_icon': 'fa-files-o'
    }
    title = gettext_noop('Enable a Backup Schedule')
    actions_ = [{
        'type': 'link',
        'class': 'primary',
        'text': gettext_noop('Go to {app_name}'),
        'url': 'backups:index'
    }, {
        'type': 'dismiss'
    }]
    Notification.update_or_create(id='backups-remote-schedule',
                                  app_id='backups', severity='info',
                                  title=title, message=message,
                                  actions=actions_, data=data, group='admin')


def on_schedule_save(repository):
    """Dismiss notification. Called when repository's schedule is updated."""
    if not repository.schedule.enabled:
        return

    from plinth.notification import Notification
    try:
        note = Notification.get('backups-remote-schedule')
        note.dismiss()
    except KeyError:
        pass


def _show_schedule_error_notification(repository, is_error, exception=None):
    """Show or hide a notification related scheduled backup operation."""
    from plinth.notification import Notification
    id_ = 'backups-schedule-error-' + repository.uuid
    try:
        note = Notification.get(id_)
        error_count = note.data['error_count']
    except KeyError:
        error_count = 0

    message = gettext_noop(
        'A scheduled backup failed. Past {error_count} attempts for backup '
        'did not succeed. The latest error is: {error_message}')
    data = {
        'app_name': 'translate:' + gettext_noop('Backups'),
        'app_icon': 'fa-files-o',
        'error_count': error_count + 1 if is_error else 0,
        'error_message': str(exception)
    }
    title = gettext_noop('Error During Backup')
    actions_ = [{
        'type': 'link',
        'class': 'primary',
        'text': gettext_noop('Go to {app_name}'),
        'url': 'backups:index'
    }, {
        'type': 'dismiss'
    }]
    note = Notification.update_or_create(id=id_, app_id='backups',
                                         severity='error', title=title,
                                         message=message, actions=actions_,
                                         data=data, group='admin')
    note.dismiss(should_dismiss=not is_error)
