#!/usr/bin/env python3

# Libervia types
# Copyright (C) 2011  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/>.

from abc import ABC, abstractmethod
from collections import namedtuple
from typing import Any, TYPE_CHECKING, Type, TypeVar

from twisted.internet import defer
from twisted.python import failure
from twisted.words.protocols.jabber import jid as t_jid
from twisted.words.protocols.jabber import xmlstream as mod_xmlstream
from twisted.words.xish import domish
from tx_xmpp import disco
from typing_extensions import TypedDict

from libervia.backend.core import exceptions
from libervia.backend.models.core import MessageData

if TYPE_CHECKING:
    from libervia.backend.core.xmpp import LiberviaRosterProtocol, SatDiscoProtocol
    from libervia.backend.models.tor import TorMode
    from treq.client import HTTPClient
    from txtorcon import Tor


D = TypeVar("D")


EncryptionPlugin = namedtuple(
    "EncryptionPlugin", ("instance", "name", "namespace", "priority", "directed")
)


class EncryptionSession(TypedDict):
    plugin: EncryptionPlugin


# Incomplete types built through observation rather than code inspection.
MessageDataExtra = TypedDict(
    "MessageDataExtra",
    {"encrypted": bool, "origin_id": str, "headers": dict[str, Any]},
    total=False,
)


# FIXME: deprecated, do not use this type. Use libervia.backend.models.core.MessageData
#   instead.
MessageDataLegacy = TypedDict(
    "MessageDataLegacy",
    {
        "from": t_jid.JID,
        "to": t_jid.JID,
        "uid": str,
        "message": dict[str, str],
        "subject": dict[str, str],
        "type": str,
        "timestamp": float,
        "extra": MessageDataExtra,
        "ENCRYPTION": EncryptionSession,
        "xml": domish.Element,
    },
    total=False,
)


class SatXMPPEntity(ABC):
    """Base class for Client and Component."""

    profile: str
    jid: t_jid.JID
    is_component: bool
    server_jid: t_jid.JID
    port: int
    disco: "SatDiscoProtocol"
    discoHandler: disco.DiscoHandler
    identities: list[disco.DiscoIdentity]
    xmlstream: mod_xmlstream.XmlStream
    tor: "Tor | None" = None

    def __init__(self) -> None:
        self.plugin_data: dict[str, Any] = {}

    @property
    def tor_mode(self) -> "TorMode":
        """Return Tor mode for this profile."""
        raise NotImplementedError

    def get_plugin_data(self, import_name: str, plugin_data_model: Type[D]) -> D:
        """Retrieve data for a plugin.

        Those data are per profile session (i.e. client instance).
        If there is no data for this plugin in this instance, it will be created with not
        arguments, which means that the model must have default value for every field.
        If it's not the case, use "set_plugin_data" before the first call of
        "get_plugin_data".
        """
        try:
            data = self.plugin_data[import_name]
        except KeyError:
            data = self.plugin_data[import_name] = plugin_data_model()

        return data

    def set_plugin_data(self, import_name: str, plugin_data: object) -> None:
        """Set plugin data.

        This will store plugin data in current client instance.
        """
        if import_name in self.plugin_data:
            raise exceptions.ConfigError("Plugin data already stored in {import_name!r}")
        self.plugin_data[import_name] = plugin_data

    @property
    @abstractmethod
    def treq(self) -> "HTTPClient":
        """Get the HTTP client.

        @return (HTTPClient): the HTTP client
        """
        raise NotImplementedError

    @property
    @abstractmethod
    def treq_ignore_tls(self) -> "HTTPClient":
        """Get the HTTP client ignoring TLS errors.

        @return (HTTPClient): the HTTP client ignoring TLS errors
        """
        raise NotImplementedError

    @classmethod
    @abstractmethod
    async def start_connection(cls, host, profile, max_retries):
        raise NotImplementedError

    @abstractmethod
    def disconnect_profile(self, reason: failure.Failure | None) -> None:
        """Disconnect the profile."""
        raise NotImplementedError

    @abstractmethod
    def is_connected(self) -> bool:
        """Return True is client is fully connected

        client is considered fully connected if transport is started and all plugins
        are initialised
        """
        raise NotImplementedError

    @abstractmethod
    def entity_disconnect(self) -> defer.Deferred[None]:
        raise NotImplementedError

    ## sending ##

    @abstractmethod
    def IQ(self, type_="set", timeout=60) -> mod_xmlstream.IQ:
        """shortcut to create an IQ element managing deferred

        @param type_(unicode): IQ type ('set' or 'get')
        @param timeout(None, int): timeout in seconds
        @return((D)domish.Element: result stanza
            errback is called if an error stanza is returned
        """
        raise NotImplementedError

    @abstractmethod
    def sendError(
        self,
        iq_elt: domish.Element,
        condition: str,
        text: str | None = None,
        appCondition: str | None = None,
    ) -> None:
        """Send error stanza build from iq_elt

        @param iq_elt(domish.Element): initial IQ element
        @param condition(unicode): error condition
        """
        raise NotImplementedError

    @abstractmethod
    def generate_message_xml(
        self,
        data: MessageData,
        post_xml_treatments: defer.Deferred | None = None,
    ) -> MessageData:
        """Generate <message/> stanza from message data

        @param data: message data
            domish element will be put in data['xml']
            following keys are needed:
                - from
                - to
                - uid: can be set to '' if uid attribute is not wanted
                - message
                - type
                - subject
                - extra
        @param post_xml_treatments: a Deferred which will be called with data once XML is
            generated
        @return: message data
        """
        raise NotImplementedError

    @property
    @abstractmethod
    def is_admin(self) -> bool:
        """True if a client is an administrator with extra privileges"""
        raise NotImplementedError

    @abstractmethod
    def add_post_xml_callbacks(self, post_xml_treatments) -> None:
        """Used to add class level callbacks at the end of the workflow

        @param post_xml_treatments(D): the same Deferred as in sendMessage trigger
        """
        raise NotImplementedError

    @abstractmethod
    async def a_send(self, obj: domish.Element) -> None:
        raise NotImplementedError

    @abstractmethod
    def send(self, obj: domish.Element) -> None:
        # We need to call super() due to mulitple inheritance: Wokkel XMPPClient or
        # Component's `send` needs to be called.
        super().send(obj)  # type: ignore

    @abstractmethod
    async def send_message_data(self, mess_data: MessageData) -> MessageData:
        """Convenient method to send message data to stream

        This method will send mess_data[u'xml'] to stream, but a trigger is there
        The trigger can't be cancelled, it's a good place for e2e encryption which
        don't handle full stanza encryption
        This trigger can return a Deferred (it's an async_point)
        @param mess_data(dict): message data as constructed by onMessage workflow
        @return (dict): mess_data (so it can be used in a deferred chain)
        """
        raise NotImplementedError

    @abstractmethod
    def sendMessage(
        self,
        to_jid,
        message,
        subject=None,
        mess_type="auto",
        extra=None,
        uid=None,
        no_trigger=False,
    ):
        """Send a message to an entity

        @param to_jid(jid.JID): destinee of the message
        @param message(dict): message body, key is the language (use '' when unknown)
        @param subject(dict): message subject, key is the language (use '' when unknown)
        @param mess_type(str): one of standard message type (cf RFC 6121 §5.2.2) or:
            - auto: for automatic type detection
            - info: for information ("info_type" can be specified in extra)
        @param extra(dict, None): extra data. Key can be:
            - info_type: information type, can be
                TODO
        @param uid(unicode, None): unique id:
            should be unique at least in this XMPP session
            if None, an uuid will be generated
        @param no_trigger (bool): if True, sendMessage[suffix] trigger will no be used
            useful when a message need to be sent without any modification
            ⚠ this will also skip encryption methods!
        """
        raise NotImplementedError

    @abstractmethod
    def is_message_printable(self, mess_data: MessageData) -> bool:
        """Return True if a message contain payload to show in frontends"""
        raise NotImplementedError

    @abstractmethod
    async def message_add_to_history(self, data):
        """Store message into database (for local history)

        @param data: message data dictionnary
        @param client: profile's client
        """
        raise NotImplementedError

    @abstractmethod
    def message_get_bridge_args(self, data):
        """Generate args to use with bridge from data dict"""
        raise NotImplementedError

    @abstractmethod
    def message_send_to_bridge(self, data):
        """Send message to bridge, so frontends can display it

        @param data: message data dictionnary
        """
        raise NotImplementedError

    ## helper methods ##

    @abstractmethod
    def p(self, plugin_name, missing=exceptions.MissingModule):
        """Get a plugin if available

        @param plugin_name(str): name of the plugin
        @param missing(object): value to return if plugin is missing
            if it is a subclass of Exception, it will be raised with a helping str as
            argument.
        @return (object): requested plugin wrapper, or default value
            The plugin wrapper will return the method with client set as first
            positional argument
        """
        raise NotImplementedError


class SatXMPPClient(SatXMPPEntity):
    roster: "LiberviaRosterProtocol"


class SatXMPPComponent(SatXMPPEntity):

    def is_local(self, jid_: t_jid.JID) -> bool:
        """Returns True if jid_ use a domain or subdomain of component's host"""
        raise NotImplementedError
