# This file is part of Gajim.
#
# SPDX-License-Identifier: GPL-3.0-only

# XEP-0048: Bookmarks

from __future__ import annotations

from typing import Any
from typing import cast
from typing import Literal

from gi.repository import GLib
from nbxmpp.namespaces import Namespace
from nbxmpp.protocol import JID
from nbxmpp.structs import BookmarkData
from nbxmpp.structs import DiscoInfo
from nbxmpp.structs import MessageProperties
from nbxmpp.task import Task

from gajim.common import app
from gajim.common import types
from gajim.common.events import BookmarksReceived
from gajim.common.modules.base import BaseModule
from gajim.common.modules.contacts import GroupchatContact
from gajim.common.modules.util import event_node

NODE_MAX_NS = "http://jabber.org/protocol/pubsub#config-node-max"


class Bookmarks(BaseModule):
    def __init__(self, con: types.Client) -> None:
        BaseModule.__init__(self, con)
        self._register_pubsub_handler(self._bookmark_event_received)
        self._register_pubsub_handler(self._bookmark_1_event_received)
        self._conversion = False
        self._compat = False
        self._compat_pep = False
        self._node_max = False
        self._bookmarks: types.BookmarksDict = {}
        self._join_timeouts: list[int] = []
        self._request_in_progress = True

    @property
    def conversion(self) -> bool:
        return self._conversion

    @property
    def compat(self) -> bool:
        return self._compat

    @property
    def compat_pep(self) -> bool:
        return self._compat_pep

    @property
    def bookmarks(self) -> list[BookmarkData]:
        return list(self._bookmarks.values())

    @property
    def pep_bookmarks_used(self) -> bool:
        return self._bookmark_module() == "PEPBookmarks"

    @property
    def nativ_bookmarks_used(self) -> bool:
        return self._bookmark_module() == "NativeBookmarks"

    @event_node(Namespace.BOOKMARKS)
    def _bookmark_event_received(
        self, _con: types.NBXMPPClient, _stanza: Any, properties: MessageProperties
    ) -> None:
        assert properties.pubsub_event is not None
        if properties.pubsub_event.retracted:
            return

        if not properties.is_self_message:
            self._log.warning("%s has an open access bookmarks node", properties.jid)
            return

        if not self.pep_bookmarks_used:
            return

        if self._request_in_progress:
            self._log.info("Ignore update, pubsub request in progress")
            return

        bookmarks = self._convert_to_dict(properties.pubsub_event.data)

        old_bookmarks = self._bookmarks.copy()
        self._bookmarks = bookmarks
        self._act_on_changed_bookmarks(old_bookmarks)
        app.ged.raise_event(BookmarksReceived(account=self._account))

    @event_node(Namespace.BOOKMARKS_1)
    def _bookmark_1_event_received(
        self, _con: types.NBXMPPClient, _stanza: Any, properties: MessageProperties
    ) -> None:
        if not properties.is_self_message:
            self._log.warning("%s has an open access bookmarks node", properties.jid)
            return

        if not self.nativ_bookmarks_used:
            return

        if self._request_in_progress:
            self._log.info("Ignore update, pubsub request in progress")
            return

        old_bookmarks = self._bookmarks.copy()

        assert properties.pubsub_event is not None
        if properties.pubsub_event.deleted or properties.pubsub_event.purged:
            self._log.info("Bookmark node deleted/purged")
            self._bookmarks.clear()

        elif properties.pubsub_event.retracted:
            try:
                jid = JID.from_string(properties.pubsub_event.id)
            except Exception as error:
                self._log.info(
                    "Unable to parse retracted bookmark: %s %s",
                    properties.pubsub_event.id,
                    error,
                )
            else:
                self._log.info("Retract: %s", jid)
                bookmark = self._bookmarks.get(jid)
                if bookmark is not None:
                    self._bookmarks.pop(jid, None)

        else:
            new_bookmark = cast(BookmarkData, properties.pubsub_event.data)
            self._bookmarks[new_bookmark.jid] = new_bookmark

        self._act_on_changed_bookmarks(old_bookmarks)
        app.ged.raise_event(BookmarksReceived(account=self._account))

    def pass_disco(self, info: DiscoInfo) -> None:
        self._node_max = NODE_MAX_NS in info.features
        self._compat_pep = Namespace.BOOKMARKS_COMPAT_PEP in info.features
        self._compat = Namespace.BOOKMARKS_COMPAT in info.features
        self._conversion = Namespace.BOOKMARK_CONVERSION in info.features

    def _bookmark_module(
        self,
    ) -> Literal["PrivateBookmarks", "NativeBookmarks", "PEPBookmarks"]:
        if not self._con.get_module("PubSub").publish_options:
            return "PrivateBookmarks"

        if app.settings.get("dev_force_bookmark_2"):
            return "NativeBookmarks"

        if self._compat_pep and self._node_max:
            return "NativeBookmarks"

        if self._conversion:
            return "PEPBookmarks"
        return "PrivateBookmarks"

    def _act_on_changed_bookmarks(self, current_bookmarks: types.BookmarksDict) -> None:

        changed_autojoin, changed_name = self._get_changed_attrs(
            current_bookmarks, self._bookmarks
        )

        if changed_name:
            app.storage.archive.bulk_update_custom_names(self._account, changed_name)

        join_bookmarks = [
            self._bookmarks[jid] for jid, autojoin in changed_autojoin if autojoin
        ]
        self._log.info(
            "Schedule autojoin in 10s for: %s\n", "\n".join(str(join_bookmarks))
        )

        # If another client creates a MUC, the MUC is locked until the
        # configuration is finished. Give the user some time to finish
        # the configuration.
        if app.settings.get_account_setting(self._account, "autojoin_sync"):
            timeout_id = GLib.timeout_add_seconds(
                10, self._join_with_timeout, join_bookmarks
            )
            self._join_timeouts.append(timeout_id)

        # TODO: leave mucs
        # leave = [jid for jid, autojoin in changed if not autojoin]

    @staticmethod
    def _get_changed_attrs(
        old_bookmarks: types.BookmarksDict,
        new_bookmarks: types.BookmarksDict,
    ) -> tuple[set[tuple[JID, bool]], set[tuple[JID, str | None]]]:

        new_autojoin: set[tuple[JID, bool]] = set()
        new_name: set[tuple[JID, str | None]] = set()
        for b in new_bookmarks.values():
            new_autojoin.add((b.jid, b.autojoin))
            new_name.add((b.jid, b.name))

        old_autojoin: set[tuple[JID, bool]] = set()
        old_name: set[tuple[JID, str | None]] = set()
        for b in old_bookmarks.values():
            old_autojoin.add((b.jid, b.autojoin))
            old_name.add((b.jid, b.name))

        changed_autojoin = new_autojoin - old_autojoin
        changed_name = new_name - old_name
        return changed_autojoin, changed_name

    @staticmethod
    def _convert_to_dict(bookmarks: list[BookmarkData] | None) -> types.BookmarksDict:
        _dict: types.BookmarksDict = {}
        if not bookmarks:
            return _dict

        for bookmark in bookmarks:
            _dict[bookmark.jid] = bookmark
        return _dict

    def get_bookmark(self, jid: str | JID) -> BookmarkData | None:
        return self._bookmarks.get(cast(JID, jid))

    def request_bookmarks(self) -> None:
        if not app.account_is_available(self._account):
            return

        self._request_in_progress = True
        self._nbxmpp(self._bookmark_module()).request_bookmarks(
            callback=self._bookmarks_received
        )

    def _bookmarks_received(self, task: Task) -> None:
        try:
            bookmarks: list[BookmarkData] = task.finish()
        except Exception as error:
            self._log.warning(error)
            bookmarks = []

        self._request_in_progress = False

        self._client.get_module("MucBlocking").pass_bookmarks(bookmarks)

        self._cleanup_bookmarks(bookmarks)
        self._bookmarks = self._convert_to_dict(bookmarks)
        if app.settings.get_account_setting(self._account, "autojoin_sync"):
            self.auto_join_bookmarks(self.bookmarks)
        app.ged.raise_event(BookmarksReceived(account=self._account))

    def _cleanup_bookmarks(self, bookmarks: list[BookmarkData]) -> None:
        for bookmark in list(bookmarks):
            contact = self._client.get_module("Contacts").get_contact(
                bookmark.jid, groupchat=True
            )
            if not isinstance(contact, GroupchatContact):
                # The contact exists probably in the roster and is therefore
                # assumed to not be a MUC
                self._log.warning(
                    "Received bookmark but jid is not a groupchat: %s", bookmark.jid
                )
                bookmarks.remove(bookmark)

    def store_bookmarks(self, bookmarks: list[BookmarkData]) -> None:
        if not app.account_is_available(self._account):
            return

        if not self.nativ_bookmarks_used:
            bookmarks = self.bookmarks

        self._nbxmpp(self._bookmark_module()).store_bookmarks(bookmarks)

        app.ged.raise_event(BookmarksReceived(account=self._account))

    def _join_with_timeout(self, bookmarks: list[BookmarkData]) -> None:
        self._join_timeouts.pop(0)
        self.auto_join_bookmarks(bookmarks)

    def auto_join_bookmarks(self, bookmarks: list[BookmarkData]) -> None:
        for bookmark in bookmarks:
            if bookmark.autojoin:
                self._log.info("Autojoin Bookmark: %s", bookmark.jid)
                self._con.get_module("MUC").join(bookmark.jid)

    def modify(self, jid: JID, **kwargs: Any) -> None:
        bookmark = self._bookmarks.get(jid)
        if bookmark is None:
            return

        if "name" in kwargs:
            app.storage.archive.set_contact_value(
                self._account, jid, "custom_name", kwargs["name"]
            )

        new_bookmark = bookmark._replace(**kwargs)
        if new_bookmark == bookmark:
            # No change happened
            return
        self._log.info("Modify bookmark: %s %s", jid, kwargs)
        self._bookmarks[jid] = new_bookmark

        self.store_bookmarks([new_bookmark])

    def add_or_modify(self, jid: JID, **kwargs: Any) -> None:
        if not app.settings.get_account_setting(self._account, "autojoin_sync"):
            kwargs.pop("autojoin", None)
        bookmark = self._bookmarks.get(jid)
        if bookmark is not None:
            self.modify(jid, **kwargs)
            return

        new_bookmark = BookmarkData(jid=jid, **kwargs)
        self._bookmarks[jid] = new_bookmark
        self._log.info("Add new bookmark: %s", new_bookmark)

        app.storage.archive.set_contact_value(
            self._account, jid, "custom_name", new_bookmark.name
        )

        self.store_bookmarks([new_bookmark])

    def remove(self, jid: JID, publish: bool = True) -> None:
        removed = self._bookmarks.pop(jid, False)
        if not removed:
            return
        if publish:
            if self.nativ_bookmarks_used:
                self._nbxmpp("NativeBookmarks").retract_bookmark(jid)
            else:
                self.store_bookmarks(self.bookmarks)

    def get_name_from_bookmark(self, jid: str | JID) -> str | None:
        bookmark = self._bookmarks.get(cast(JID, jid))
        if bookmark is None:
            return bookmark
        return bookmark.name

    def is_bookmark(self, jid: str | JID) -> bool:
        return jid in self._bookmarks

    def _remove_timeouts(self) -> None:
        for _id in self._join_timeouts:
            GLib.source_remove(_id)

    def cleanup(self) -> None:
        BaseModule.cleanup(self)
        self._remove_timeouts()
