#!/usr/bin/env python3

# Libervia plugin to manage file sharing component through ad-hoc commands
# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import os.path
from functools import partial
from typing import cast
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
from libervia.backend.core.core_types import SatXMPPEntity
from libervia.backend.core.i18n import _, D_
from libervia.backend.core import exceptions
from libervia.backend.core.constants import Const as C
from libervia.backend.core.log import getLogger
from libervia.backend.plugins.plugin_xep_0004 import Boolean, DataForm, Text
from libervia.backend.plugins.plugin_xep_0050 import (
    XEP_0050,
    AdHocCallbackData,
    AdHocCommand,
    AdHocError,
    Note,
    PageData,
    Status,
    Error,
)
from libervia.backend.plugins.plugin_xep_0264 import XEP_0264
from libervia.backend.tools.common import utils

log = getLogger(__name__)


PLUGIN_INFO = {
    C.PI_NAME: "File Sharing Management",
    C.PI_IMPORT_NAME: "FILE_SHARING_MANAGEMENT",
    C.PI_MODES: [C.PLUG_MODE_COMPONENT],
    C.PI_TYPE: "EXP",
    C.PI_PROTOCOLS: [],
    C.PI_DEPENDENCIES: ["XEP-0050", "XEP-0264"],
    C.PI_RECOMMENDATIONS: [],
    C.PI_MAIN: "FileSharingManagement",
    C.PI_HANDLER: "no",
    C.PI_DESCRIPTION: _(
        "Experimental handling of file management for file sharing. This plugins allows "
        "to change permissions of stored files/directories or remove them."
    ),
}

NS_FILE_MANAGEMENT = "https://salut-a-toi.org/protocol/file-management:0"
NS_FILE_MANAGEMENT_PERM = "https://salut-a-toi.org/protocol/file-management:0#perm"
NS_FILE_MANAGEMENT_DELETE = "https://salut-a-toi.org/protocol/file-management:0#delete"
NS_FILE_MANAGEMENT_THUMB = "https://salut-a-toi.org/protocol/file-management:0#thumb"
NS_FILE_MANAGEMENT_QUOTA = "https://salut-a-toi.org/protocol/file-management:0#quota"


ROOT_FORM = DataForm(
    type="form",
    title="File Management",
    namespace=NS_FILE_MANAGEMENT,
    fields=[
        Text(var="path", required=True),
        Text(var="namespace", required=True),
    ],
)


class FileSharingManagement:
    # This is a temporary way (Q&D) to handle stored files, a better way (using pubsub
    # syntax?) should be elaborated and proposed as a standard.

    def __init__(self, host):
        log.info(_("File Sharing Management plugin initialization"))
        self.host = host
        self._c = cast(XEP_0050, host.plugins["XEP-0050"])
        self._t = cast(XEP_0264, host.plugins["XEP-0264"])
        self.files_path = host.get_local_path(None, C.FILES_DIR)
        host.bridge.add_method(
            "file_sharing_delete",
            ".plugin",
            in_sign="ssss",
            out_sign="",
            method=self._delete,
            async_=True,
        )

    def profile_connected(self, client):
        self._c.register_ad_hoc_command(
            client,
            AdHocCommand(
                callback=self._on_change_file,
                label=D_("Change Permissions of File(s)"),
                node=NS_FILE_MANAGEMENT_PERM,
                allowed_magics=[C.ENTITY_ALL],
                form=ROOT_FORM,
            ),
        )
        self._c.register_ad_hoc_command(
            client,
            AdHocCommand(
                callback=self._on_delete_file,
                label=D_("Delete File(s)"),
                node=NS_FILE_MANAGEMENT_DELETE,
                allowed_magics=[C.ENTITY_ALL],
                form=ROOT_FORM,
            ),
        )
        self._c.register_ad_hoc_command(
            client,
            AdHocCommand(
                callback=self._on_gen_thumbnails,
                label=D_("Generate Thumbnails"),
                node=NS_FILE_MANAGEMENT_THUMB,
                allowed_magics=[C.ENTITY_ALL],
                form=ROOT_FORM,
            ),
        )
        self._c.register_ad_hoc_command(
            client,
            AdHocCommand(
                callback=self._on_quota,
                label=D_("Get Quota"),
                node=NS_FILE_MANAGEMENT_QUOTA,
                allowed_magics=[C.ENTITY_ALL],
            ),
        )

    def _delete(self, service_jid_s, path, namespace, profile):
        client = self.host.get_client(profile)
        service_jid = jid.JID(service_jid_s) if service_jid_s else None
        return defer.ensureDeferred(
            self._c.sequence(
                client,
                [{"path": path, "namespace": namespace}, {"confirm": True}],
                NS_FILE_MANAGEMENT_DELETE,
                service_jid,
            )
        )

    async def _get_file_data(self, client: SatXMPPEntity, page_data: PageData) -> dict:
        """Retrieve field requested in root form.

        "found_file" will also be set in session_data.
        @param client: SatXMPPEntity
        @param page_data: Data of the page.
        @return: found file data

        @raise AdHocError: something is wrong
        """
        try:
            path = page_data.form.get_field("path", Text).value
            namespace = page_data.form.get_field("namespace", Text).value
        except KeyError as e:
            raise AdHocError(Error.BAD_PAYLOAD) from e

        if path is None:
            raise AdHocError(Error.BAD_PAYLOAD)

        if not (path := path.strip()):
            raise AdHocError(Error.BAD_PAYLOAD)

        path = path.rstrip("/")
        parent_path, basename = os.path.split(path)

        # TODO: if parent_path and basename are empty, we ask for root directory
        #       this must be managed

        try:
            found_files = await self.host.memory.get_files(
                client,
                page_data.requestor,
                path=parent_path,
                name=basename,
                namespace=namespace or None,
            )
            found_file = found_files[0]
        except (exceptions.NotFound, IndexError):
            raise AdHocError(Error.ITEM_NOT_FOUND, text=_("file not found"))
        except exceptions.PermissionError:
            raise AdHocError(Error.FORBIDDEN)

        if found_file["owner"] != page_data.requestor:
            # only owner can manage files
            log.warning(_("Only owner can manage files"))
            raise AdHocError(Error.FORBIDDEN)

        page_data.session_data["found_file"] = found_file
        page_data.session_data["namespace"] = namespace
        return found_file

    def _update_read_permission(self, access, allowed_jids):
        if not allowed_jids:
            if C.ACCESS_PERM_READ in access:
                del access[C.ACCESS_PERM_READ]
        elif allowed_jids == "PUBLIC":
            access[C.ACCESS_PERM_READ] = {"type": C.ACCESS_TYPE_PUBLIC}
        else:
            access[C.ACCESS_PERM_READ] = {
                "type": C.ACCESS_TYPE_WHITELIST,
                "jids": [j.full() for j in allowed_jids],
            }

    async def _update_dir(self, client, requestor, namespace, file_data, allowed_jids):
        """Recursively update permission of a directory and all subdirectories

        @param file_data(dict): metadata of the file
        @param allowed_jids(list[jid.JID]): list of entities allowed to read the file
        """
        assert file_data["type"] == C.FILE_TYPE_DIRECTORY
        files_data = await self.host.memory.get_files(
            client, requestor, parent=file_data["id"], namespace=namespace or None
        )

        for file_data in files_data:
            if not file_data["access"].get(C.ACCESS_PERM_READ, {}):
                log.debug(
                    "setting {perm} read permission for {name}".format(
                        perm=allowed_jids, name=file_data["name"]
                    )
                )
                await self.host.memory.file_update(
                    file_data["id"],
                    "access",
                    partial(self._update_read_permission, allowed_jids=allowed_jids),
                )
            if file_data["type"] == C.FILE_TYPE_DIRECTORY:
                await self._update_dir(client, requestor, namespace, file_data, "PUBLIC")

    async def _on_change_file(
        self,
        client: SatXMPPEntity,
        page_data: PageData,
    ) -> AdHocCallbackData:
        """Change permissions of a file or directory.

        @param client: Libervia client instance.
        @param page_data: Ad-Hoc page data.
        @return: Command results.
        @raise AdHocError: Something went wrong during the workflow.
        """
        form = page_data.form
        session_data = page_data.session_data
        if page_data.idx == 1:
            # We retrieve the file and ask for permissions.
            found_file = await self._get_file_data(client, page_data)

            # management request
            if found_file["type"] == C.FILE_TYPE_DIRECTORY:
                instructions = D_("Please select permissions for this directory")
            else:
                instructions = D_("Please select permissions for this file")

            read_allowed_field = Text(
                type="text-multi",
                var="read_allowed",
                desc=D_(
                    "list of jids allowed to read this file (beside yourself), or "
                    '"PUBLIC" to let a public access'
                ),
            )
            form = DataForm(
                title=D_("File Management"),
                instructions=instructions,
                namespace=NS_FILE_MANAGEMENT,
                fields=[read_allowed_field],
            )
            read_access = found_file["access"].get(C.ACCESS_PERM_READ, {})
            access_type = read_access.get("type", C.ACCESS_TYPE_WHITELIST)
            if access_type == C.ACCESS_TYPE_PUBLIC:
                read_allowed_field.value = "PUBLIC"
            else:
                read_allowed_field.value = "\n".join(read_access.get("jids", []))
            if found_file["type"] == C.FILE_TYPE_DIRECTORY:
                form.fields.append(
                    Boolean(
                        var="recursive",
                        value=False,
                        desc=D_(
                            "Files under it will be made public to follow this dir"
                            " permission (only if they don't have already a permission"
                            " set)."
                        ),
                    )
                )

            return AdHocCallbackData(form=form, status=Status.EXECUTING)

        else:
            # final phase, we'll do permission change here
            found_file = session_data["found_file"]

            try:
                read_allowed = form.get_field("read_allowed", Text)
                if read_allowed.value is None:
                    raise KeyError
            except KeyError:
                raise AdHocError(Error.BAD_PAYLOAD)

            if read_allowed.value == "PUBLIC":
                allowed_jids = "PUBLIC"
            elif read_allowed.value.strip() == "":
                allowed_jids = None
            else:
                try:
                    lines = [line.strip() for line in read_allowed.value.splitlines()]
                    allowed_jids = [jid.JID(v) for v in lines if v]
                except RuntimeError as e:
                    log.warning(
                        _("Can't use read_allowed values: {reason}").format(reason=e)
                    )
                    raise AdHocError(Error.BAD_PAYLOAD, text=str(e))

            if found_file["type"] == C.FILE_TYPE_FILE:
                await self.host.memory.file_update(
                    found_file["id"],
                    "access",
                    partial(self._update_read_permission, allowed_jids=allowed_jids),
                )
            else:
                try:
                    recursive = form.get_field("recursive", Boolean)
                except KeyError:
                    recursive = False
                await self.host.memory.file_update(
                    found_file["id"],
                    "access",
                    partial(self._update_read_permission, allowed_jids=allowed_jids),
                )
                if recursive:
                    # we set all file under the directory as public (if they haven't
                    # already a permission set), so allowed entities of root directory
                    # can read them.
                    namespace = session_data["namespace"]
                    await self._update_dir(
                        client, page_data.requestor, namespace, found_file, "PUBLIC"
                    )

            # job done, we can end the session
            return AdHocCallbackData(
                status=Status.COMPLETED,
                notes=[Note(text=_("management session done"))],
            )

    async def _on_delete_file(
        self,
        client: SatXMPPEntity,
        page_data: PageData,
    ) -> AdHocCallbackData:
        """Delete a file or directory.

        @param client: Libervia client instance.
        @param page_data: Ad-Hoc page data.
        @return: Command results.
        @raise AdHocError: Something went wrong during the workflow.
        """
        session_data = page_data.session_data

        if page_data.idx == 1:
            found_file = await self._get_file_data(client, page_data)
            session_data["found_file"] = found_file
            if found_file["type"] == C.FILE_TYPE_DIRECTORY:
                msg = D_(
                    "Are you sure to delete directory {name} and all files and "
                    "directories under it?"
                ).format(name=found_file["name"])
            else:
                msg = D_(
                    "Are you sure to delete file {name}?".format(name=found_file["name"])
                )
            form = DataForm(
                title=D_("File Management"),
                instructions=msg,
                namespace=NS_FILE_MANAGEMENT,
                fields=[
                    Boolean(
                        var="confirm",
                        value=False,
                        required=True,
                        desc=D_("Check this box to confirm."),
                    )
                ],
            )

            return AdHocCallbackData(form=form, status=Status.EXECUTING)
        else:
            # final phase, we'll do deletion here
            found_file = session_data["found_file"]
            try:
                confirmed = page_data.form.get_field("confirm", Boolean).value
            except KeyError:
                raise AdHocError(Error.BAD_PAYLOAD)
            if not confirmed:
                notes = []
            else:
                recursive = found_file["type"] == C.FILE_TYPE_DIRECTORY
                await self.host.memory.file_delete(
                    client, page_data.requestor, found_file["id"], recursive
                )
                notes = [Note(text=D_("file deleted"))]
            return AdHocCallbackData(status=Status.COMPLETED, notes=notes)

    def _update_thumbs(self, extra, thumbnails):
        extra[C.KEY_THUMBNAILS] = thumbnails

    async def _gen_thumbs(self, client, requestor, namespace, file_data):
        """Recursively generate thumbnails

        @param file_data(dict): metadata of the file
        """
        if file_data["type"] == C.FILE_TYPE_DIRECTORY:
            sub_files_data = await self.host.memory.get_files(
                client, requestor, parent=file_data["id"], namespace=namespace or None
            )
            for sub_file_data in sub_files_data:
                await self._gen_thumbs(client, requestor, namespace, sub_file_data)

        elif file_data["type"] == C.FILE_TYPE_FILE:
            media_type = file_data["media_type"]
            file_path = os.path.join(self.files_path, file_data["file_hash"])
            if media_type == "image":
                thumbnails = []

                for max_thumb_size in self._t.SIZES:
                    try:
                        thumb_size, thumb_id = await self._t.generate_thumbnail(
                            file_path,
                            max_thumb_size,
                            #  we keep thumbnails for 6 months
                            60 * 60 * 24 * 31 * 6,
                        )
                    except Exception as e:
                        log.warning(
                            _("Can't create thumbnail: {reason}").format(reason=e)
                        )
                        break
                    thumbnails.append({"id": thumb_id, "size": thumb_size})

                await self.host.memory.file_update(
                    file_data["id"],
                    "extra",
                    partial(self._update_thumbs, thumbnails=thumbnails),
                )

                log.info(
                    "thumbnails for [{file_name}] generated".format(
                        file_name=file_data["name"]
                    )
                )

        else:
            log.warning("unmanaged file type: {type_}".format(type_=file_data["type"]))

    async def _on_gen_thumbnails(
        self,
        client: SatXMPPEntity,
        page_data: PageData,
    ) -> AdHocCallbackData:
        """Generate thumbnails for a file or directory.

        @param client: Libervia client instance.
        @param page_data: Ad-Hoc page data.
        @return: Command results.
        @raise AdHocError: Something went wrong during the workflow.
        """
        found_file = await self._get_file_data(client, page_data)

        log.info("Generating thumbnails.")
        await self._gen_thumbs(
            client, page_data.requestor, found_file["namespace"], found_file
        )

        return AdHocCallbackData(
            status=Status.COMPLETED, notes=[Note(text=D_("thumbnails generated"))]
        )

    async def _on_quota(
        self,
        client: SatXMPPEntity,
        page_data: PageData,
    ) -> AdHocCallbackData:
        """Get current quota information.

        @param client: Libervia client instance.
        @param page_data: Ad-Hoc page data.
        @return: Command results.
        @raise AdHocError: Something went wrong during the workflow.
        """
        requestor = page_data.requestor
        quota = self.host.plugins["file-sharing"].get_quota(client, requestor)
        try:
            size_used = await self.host.memory.file_get_used_space(client, requestor)
        except exceptions.PermissionError:
            raise AdHocError(Error.FORBIDDEN)
        form = DataForm(
            type="result",
            fields=[
                Text(var="quota", value=str(quota) if quota is not None else "unlimited"),
                Text(var="used", value=str(size_used)),
            ],
        )
        note = Note(
            text=D_("You are currently using {size_used} on {size_quota}.").format(
                size_used=utils.get_human_size(size_used),
                size_quota=(
                    _("unlimited quota") if quota is None else utils.get_human_size(quota)
                ),
            ),
        )
        return AdHocCallbackData(form=form, status=Status.COMPLETED, notes=[note])
