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

# XEP-0153: vCard-Based Avatars

from __future__ import annotations

from typing import Any

import weakref
from collections.abc import Callable
from collections.abc import Generator

from nbxmpp.const import AvatarState
from nbxmpp.errors import is_error
from nbxmpp.modules.vcard_temp import VCard
from nbxmpp.namespaces import Namespace
from nbxmpp.protocol import JID
from nbxmpp.protocol import Presence
from nbxmpp.structs import DiscoInfo
from nbxmpp.structs import PresenceProperties
from nbxmpp.structs import StanzaHandler

from gajim.common import app
from gajim.common import types
from gajim.common.modules.base import BaseModule
from gajim.common.modules.contacts import BareContact
from gajim.common.modules.contacts import GroupchatContact
from gajim.common.modules.contacts import GroupchatParticipant
from gajim.common.modules.util import as_task
from gajim.common.task_manager import Task
from gajim.common.util.classes import TTLCache

VCardContactsT = BareContact | GroupchatContact | GroupchatParticipant
NS_AVATAR_HASH = "muc#roominfo_avatarhash"
NS_AVATAR_HASH_TEMP = "{http://modules.prosody.im/mod_vcard_muc}avatar#sha1"


class VCardAvatars(BaseModule):
    def __init__(self, con: types.Client) -> None:
        BaseModule.__init__(self, con)
        self._ignored_sha_cache: TTLCache[tuple[JID, str], None] = TTLCache(
            ttl_seconds=60 * 60 * 6, extend_ttl_on_hit=False
        )
        self._muc_avatar_cache: dict[JID, str] = {}
        self.avatar_conversion_available = False

        self.handlers = [
            StanzaHandler(
                name="presence",
                typ="available",
                callback=self._presence_received,
                ns=Namespace.VCARD_UPDATE,
                priority=51,
            ),
        ]

    def pass_disco(self, info: DiscoInfo) -> None:
        is_available = Namespace.VCARD_CONVERSION in info.features
        self.avatar_conversion_available = is_available
        self._log.info("Discovered Avatar Conversion")

    def get_avatar_sha(self, jid: JID) -> str | None:
        return self._muc_avatar_cache.get(jid)

    def invalidate_cache(self, jid: JID) -> None:
        self._muc_avatar_cache.pop(jid, None)

    @as_task
    def _request_vcard(
        self, contact: types.ChatContactT, expected_sha: str
    ) -> Generator[Any, Any]:
        _task = yield

        if app.app.avatar_storage.avatar_exists(expected_sha):
            # Check if avatar was already received while task was queued
            contact.update_avatar(expected_sha)
            self._log.info("Found avatar in storage: %s %s", contact.jid, expected_sha)
            return

        vcard = yield self._con.get_module("VCardTemp").request_vcard(jid=contact.jid)

        cache_key = (contact.jid, expected_sha)

        if is_error(vcard):
            self._log.warning(vcard)
            self._ignored_sha_cache.add(cache_key, None)
            return

        assert isinstance(vcard, VCard)
        avatar, avatar_sha = vcard.get_avatar()
        if avatar is None or avatar_sha is None:
            self._log.info("Avatar missing: %s %s", contact.jid, expected_sha)
            self._ignored_sha_cache.add(cache_key, None)
            return

        if expected_sha != avatar_sha:
            self._log.warning(
                "Avatar mismatch: %s %s != %s", contact.jid, expected_sha, avatar_sha
            )
            self._ignored_sha_cache.add(cache_key, None)
            return

        self._log.info("Received: %s %s", contact.jid, avatar_sha)
        app.app.avatar_storage.save_avatar(avatar)

        if isinstance(contact, BareContact | GroupchatContact):
            app.storage.archive.set_contact_value(
                self._account, contact.jid, "avatar_sha", avatar_sha
            )

        else:
            self._muc_avatar_cache[contact.jid] = avatar_sha

        contact.update_avatar(avatar_sha)

    def _presence_received(
        self,
        _con: types.NBXMPPClient,
        _stanza: Presence,
        properties: PresenceProperties,
    ) -> None:

        if properties.avatar_state in (AvatarState.IGNORE, AvatarState.NOT_READY):
            return

        assert properties.jid is not None
        if self._con.get_own_jid().bare_match(properties.jid):
            return

        if properties.from_muc:
            self._muc_update_received(properties)

        else:
            jid = properties.jid.new_as_bare()
            muc = self._con.get_module("MUC").get_muc_data(properties.jid)
            self._process_update(
                jid, properties.avatar_state, properties.avatar_sha, muc is not None
            )

    def muc_disco_info_update(self, disco_info: DiscoInfo) -> None:
        if not disco_info.supports(Namespace.VCARD):
            return

        ns_avatar_hash = None
        if disco_info.has_field(Namespace.MUC_INFO, NS_AVATAR_HASH):
            ns_avatar_hash = NS_AVATAR_HASH

        elif disco_info.has_field(Namespace.MUC_INFO, NS_AVATAR_HASH_TEMP):
            ns_avatar_hash = NS_AVATAR_HASH_TEMP

        if ns_avatar_hash is None:
            # Workaround so we don’t delete the avatar for servers that don’t
            # support sha in disco info.
            return

        avatar_sha = (
            disco_info.get_field_value(Namespace.MUC_INFO, ns_avatar_hash) or None
        )
        state = AvatarState.EMPTY if not avatar_sha else AvatarState.ADVERTISED

        assert disco_info.jid is not None
        self._process_update(disco_info.jid, state, avatar_sha, True)

    def _process_update(
        self, jid: JID, state: AvatarState, avatar_sha: str | None, groupchat: bool
    ) -> None:
        contact = self._con.get_module("Contacts").get_contact(jid, groupchat=groupchat)
        assert isinstance(contact, BareContact | GroupchatContact)

        if state == AvatarState.EMPTY:
            # Empty <photo/> tag, means no avatar is advertised
            assert avatar_sha is None
            self._log.info("%s has no avatar published", jid)
            app.storage.archive.set_contact_value(
                self._account, contact.jid, "avatar_sha", None
            )
            contact.update_avatar(avatar_sha)

        else:
            assert avatar_sha
            self._log.info("Update: %s %s", jid, avatar_sha)

            if avatar_sha == contact.avatar_sha:
                self._log.info("Avatar already known: %s %s", jid, avatar_sha)
                return

            if app.app.avatar_storage.avatar_exists(avatar_sha):
                # Check if the avatar is already in storage
                self._log.info("Found avatar in storage")
                app.storage.archive.set_contact_value(
                    self._account, contact.jid, "avatar_sha", avatar_sha
                )
                contact.update_avatar(avatar_sha)
                return

            if (jid, avatar_sha) in self._ignored_sha_cache:
                self._log.info(
                    "Avatar will be ignored because of cached error: %s %s",
                    jid,
                    avatar_sha,
                )
                return

            task = VCardAvatarsTask(contact, avatar_sha, self._request_vcard)
            app.task_manager.add_task(task)

    def _muc_update_received(self, properties: PresenceProperties) -> None:
        assert properties.jid is not None
        jid = properties.jid
        avatar_sha = properties.avatar_sha

        contact = self._con.get_module("Contacts").get_contact(
            properties.jid, groupchat=True
        )
        assert isinstance(contact, GroupchatParticipant)

        if contact.is_blocked:
            self._log.info("Ignore avatar because contact is blocked: %s", contact.jid)
            return

        # Custom ejabberd room config option
        if contact.room.get_disco() is not None:
            # We have no disco when we create a new room, but receive our
            # self presence
            allow_query = contact.room.get_config_value(
                "muc#roomconfig_allow_query_users"
            )
            if allow_query is False:
                self._log.debug("Room does not allow IQ queries: %s", contact.jid)
                return

        if properties.avatar_state == AvatarState.EMPTY:
            # Empty <photo/> tag, means no avatar is advertised
            self._log.info("%s has no avatar published", jid)
            self._muc_avatar_cache.pop(jid, None)
            contact.update_avatar()

        else:
            assert avatar_sha
            self._log.info("Update: %s %s", jid, avatar_sha)
            if not app.app.avatar_storage.avatar_exists(avatar_sha):
                if (jid, avatar_sha) in self._ignored_sha_cache:
                    self._log.info(
                        "Avatar will be ignored because of cached error: %s %s",
                        jid,
                        avatar_sha,
                    )
                    return

                app.log("avatar").info("Request avatar: %s", jid)
                task = VCardAvatarsTask(contact, avatar_sha, self._request_vcard)
                app.task_manager.add_task(task)
                return

            current_avatar_sha = self._muc_avatar_cache.get(jid)
            if current_avatar_sha != avatar_sha:
                self._log.info("%s changed their avatar: %s", jid, avatar_sha)
                self._muc_avatar_cache[jid] = avatar_sha
                contact.update_avatar()

            else:
                self._log.info("Avatar already known: %s", jid)


class VCardAvatarsTask(Task):
    def __init__(
        self, contact: VCardContactsT, sha: str, callback: Callable[..., Any]
    ) -> None:

        Task.__init__(self)
        self._contact = contact
        self._sha = sha
        self._callback = weakref.WeakMethod(callback)

    def execute(self) -> None:
        callback = self._callback()
        if callback is not None:
            callback(self._contact, self._sha)

    def preconditions_met(self) -> bool:
        try:
            client = app.get_client(self._contact.account)
        except Exception:
            self.set_obsolete()
            return False

        if not client.is_available():
            self.set_obsolete()
            return False

        if isinstance(self._contact, GroupchatParticipant):
            if not self._contact.room.is_joined:
                if self._contact.room.is_not_joined:
                    self.set_obsolete()
                return False

        return True

    def __repr__(self) -> str:
        return f"VCardAvatars ({self._contact.jid} {self._sha})"

    def __hash__(self) -> int:
        return hash((self._contact, self._sha))
