#!/usr/bin/python3

# Copyright (C) 2019 W. Martin Borgert <debacle@debian.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/>.

# Python standard library
import argparse
import asyncio
import logging
import re
import sys

# 3rd party libraries
import dbussy
import sdnotify
import slixmpp
import systemd.journal


MM_OBJECT = "org.freedesktop.ModemManager1"
MODEM_INTERFACE = MM_OBJECT + ".Modem"
MESSAGING_INTERFACE = MODEM_INTERFACE + ".Messaging"
SMS_INTERFACE = MM_OBJECT + ".Sms"


def init_logger(name, debug=False):
    log = logging.getLogger(name)
    log.addHandler(systemd.journal.JournalHandler())
    log.setLevel(logging.INFO)

    if debug:
        log.setLevel(logging.DEBUG)
        # add stdout for debugging
        log_stdout_handler = logging.StreamHandler(sys.stdout)
        log.addHandler(log_stdout_handler)

    return log


async def get_prop_async(conn, destination, path, interface, name):
    message = dbussy.Message.new_method_call(
        destination=dbussy.valid_bus_name(destination),
        path=dbussy.unsplit_path(path),
        iface=dbussy.DBUS.INTERFACE_PROPERTIES,
        method="Get",
    )
    message.append_objects("ss", interface, name)
    reply = await conn.send_await_reply(message, dbussy.DBUS.TIMEOUT_USE_DEFAULT)
    if reply is not None:
        proptype, propvalue = reply.expect_return_objects("v")[0]
        return propvalue
    else:
        raise Exception("object did not reply in time")


async def get_sms_props(conn, path):
    number = await get_prop_async(conn, MM_OBJECT, path, SMS_INTERFACE, "Number")
    smsc = await get_prop_async(conn, MM_OBJECT, path, SMS_INTERFACE, "SMSC")
    text = await get_prop_async(conn, MM_OBJECT, path, SMS_INTERFACE, "Text")
    timestamp = await get_prop_async(conn, MM_OBJECT, path, SMS_INTERFACE, "Timestamp")
    return number, smsc, text, timestamp


async def get_modem_props(conn, path):
    manufacturer = await get_prop_async(conn, MM_OBJECT, path, MODEM_INTERFACE, "Manufacturer")
    model = await get_prop_async(conn, MM_OBJECT, path, MODEM_INTERFACE, "Model")
    signalquality = await get_prop_async(
        conn, MM_OBJECT, path, MODEM_INTERFACE, "SignalQuality"
    )
    ownnumbers = await get_prop_async(conn, MM_OBJECT, path, MODEM_INTERFACE, "OwnNumbers")
    return manufacturer, model, signalquality, ownnumbers


async def delete_sms(conn, path, sms):
    message = dbussy.Message.new_method_call(
        destination=dbussy.valid_bus_name(MM_OBJECT),
        path=dbussy.unsplit_path(path),
        iface=MESSAGING_INTERFACE,
        method="Delete",
    )
    message.append_objects("o", sms)
    await conn.send_await_reply(message, dbussy.DBUS.TIMEOUT_USE_DEFAULT)


async def handle_sms(message, component, conn, logger):
    """check whether this is an SMS for us,
    get its properties and pass them to the XMPP component"""
    if (
        message.type != dbussy.DBUS.MESSAGE_TYPE_SIGNAL
        or message.interface != MESSAGING_INTERFACE
        or message.member != "Added"
    ):
        logger.debug("wrong message, ignored")
        return

    path, received = [*message.objects]

    if not received:
        logger.debug("added locally, ignored")
        return

    number, smsc, text, timestamp = await get_sms_props(conn, path)
    await component.pass_shortmessage(number, smsc, text, timestamp)

    logger.debug("deleting received %s", path)
    await delete_sms(conn, message.path, path)

    return


async def send_shortmessage(number, text, conn, modem, logger):
    message = dbussy.Message.new_method_call(
        destination=dbussy.valid_bus_name(MM_OBJECT),
        path=dbussy.unsplit_path(modem),
        iface=MESSAGING_INTERFACE,
        method="Create",
    )
    message.append_objects("a{sv}", {"number": ("s", number), "text": ("s", text)})
    reply = await conn.send_await_reply(message, dbussy.DBUS.TIMEOUT_USE_DEFAULT)
    if reply is not None:
        created = reply.expect_return_objects("o")[0]
    else:
        raise Exception("object did not reply in time")
    message = dbussy.Message.new_method_call(
        destination=dbussy.valid_bus_name(MM_OBJECT),
        path=dbussy.unsplit_path(created),
        iface=SMS_INTERFACE,
        method="Send",
    )
    reply = await conn.send_await_reply(message, dbussy.DBUS.TIMEOUT_USE_DEFAULT)
    logger.debug("deleting sent %s", created)
    await delete_sms(conn, modem, created)


class SMSComponent(slixmpp.ComponentXMPP):
    PHONE_RE = re.compile(r"^\+?[0-9]+$")

    def __init__(
        self, allowed, component, port, recipients, secret_file, xmpp_server, loop, logger
    ):
        self.allowed = allowed
        self.component = component
        self.recipients = recipients
        self.conn = None
        self.modem = None
        self.loop = loop
        self.logger = logger

        with open(secret_file) as f:
            secret = f.read().strip()

        slixmpp.ComponentXMPP.__init__(self, component, secret, xmpp_server, port)
        self.register_plugin("xep_0004")  # Data Forms
        self.register_plugin("xep_0030")  # Service Discovery
        self.register_plugin("xep_0050")  # Ad-Hoc Commands
        self.register_plugin("xep_0199")  # XMPP Ping

        self.connect()
        self.add_event_handler("message", self.message)
        self.add_event_handler("session_start", self.session_start)

    async def message(self, msg):
        if msg.get_type() not in ["normal", "chat"]:
            return  # wrong message type

        from_ = msg.get_from()
        if from_.bare not in self.allowed:
            logger.info("%s is not one of %s", from_.bare, ", ".join(self.allowed))
            msg.reply("forbidden, sorry")
            return

        phone = msg.get_to().node
        if not SMSComponent.PHONE_RE.match(phone):
            msg.reply("%s is not a valid phone number" % phone).send()
            return

        body = msg.get("body")
        if len(body) > 160:  # SMS cannot exceed 160 characters
            msg.reply("your message is too long").send()
            return

        await send_shortmessage(phone, body, self.conn, self.modem, self.logger)
        msg.reply("sent message to %s" % phone).send()

    def session_start(self, event):
        self["xep_0050"].add_command(
            node="get_modem_information",
            name="Request Modem Information",
            handler=self.request_modem_information,
        )

    async def return_me(self, recipient):
        manufacturer, model, signalquality, ownnumbers = await get_modem_props(
            self.conn, self.modem
        )
        text = "\n".join(
            [
                "Modem Information",
                "Manufacturer: %s" % manufacturer,
                "Model: %s" % model,
                "Signal Quality: %s %%" % signalquality[0],
                "Own Numbers: %s" % ", ".join(ownnumbers),
            ]
        )
        self.logger.debug("Sent modem information to " + recipient)
        self.send_message(mfrom=self.component, mto=recipient, mbody=text, mtype="chat")

    def request_modem_information(self, iq, session):
        from_ = session["from"]
        session["payload"] = None
        session["next"] = None
        session["has_next"] = False

        if from_.bare not in (self.allowed + self.recipients):
            logger.info(
                "%s is not one of %s", from_.bare, ", ".join(self.allowed + self.recipients)
            )
            value = "Not available to you."
        else:
            self.loop.create_task(self.return_me(from_.bare))
            value = "You got a message."

        form = self["xep_0004"].make_form("form", "Request Modem Information")
        form.add_field(
            var="modem_information", ftype="fixed", label="Modem Information", value=value
        )
        session["payload"] = form
        return session

    def set_conn(self, conn):
        self.conn = conn

    def set_modem(self, modem):
        self.modem = modem

    async def pass_shortmessage(self, number, smsc, text, timestamp):
        footer = "send from %s at %s via SMSC %s" % (number, timestamp, smsc)
        for recipient in self.recipients:
            self.logger.debug(footer + " to " + recipient)
            self.send_message(
                mfrom=number + "@" + self.component,
                mto=recipient,
                mbody=text + "\n" + footer,
                mtype="chat",
            )


async def get_modems(conn):
    INTERFACE_OBJECT_MANAGER = "org.freedesktop.DBus.ObjectManager"
    MM_PATH = "/org/freedesktop/ModemManager1"

    message = dbussy.Message.new_method_call(
        destination=dbussy.valid_bus_name(MM_OBJECT),
        path=dbussy.unsplit_path(MM_PATH),
        iface=INTERFACE_OBJECT_MANAGER,
        method="GetManagedObjects",
    )
    reply = await conn.send_await_reply(message, dbussy.DBUS.TIMEOUT_USE_DEFAULT)
    if reply is not None:
        return reply.expect_return_objects("a{oa{sa{sv}}}")[0]
    else:
        raise Exception("object did not reply in time")


async def receive_sms(loop, component, modem_filter, logger):
    """setup the DBus signal receiver and wait for SMS, forever in a loop"""
    conn = await dbussy.Connection.bus_get_async(
        type=dbussy.DBUS.BUS_SYSTEM, private=False, loop=loop
    )
    component.set_conn(conn)

    modem = None
    modems = await get_modems(conn)

    if modem_filter:
        if ":" in modem_filter:
            filter_type, filter_value = modem_filter.split(":", 1)
            for k, v in modems.items():
                if MODEM_INTERFACE in v:
                    if (
                        filter_type in v[MODEM_INTERFACE]
                        and v[MODEM_INTERFACE][filter_type][1] == filter_value
                    ):
                        modem = k
                        break
        else:
            logger.warning("invalid modem filter")
    elif len(modems) == 1:
        modem = list(modems.keys())[0]
    elif len(modems) > 1:
        logger.warning("more than one modem found, but no modem filter set")
    else:
        logger.warning("no modem found")

    if modem is None:
        return
    component.set_modem(modem)

    conn.bus_add_match("type='signal',interface='%s',member='Added'" % MESSAGING_INTERFACE)
    conn.enable_receive_message({dbussy.DBUS.MESSAGE_TYPE_SIGNAL})
    while True:
        message = await conn.receive_message_async()
        await handle_sms(message, component, conn, logger)


def getargs():
    ap = argparse.ArgumentParser(
        description="XMPP server component for SMS communication using ModemManager",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    ap.add_argument(
        "-a",
        "--allowed",
        help="JID allowed to send SMS (multiple JIDs possible)",
        action="append",
    )
    ap.add_argument("-c", "--component", default="sms4you.localhost", help="XMPP component JID")
    ap.add_argument("-d", "--debug", help="set log level to debug", action="store_true")
    ap.add_argument("-p", "--port", default="5347", help="XMPP component port")
    ap.add_argument(
        "-r",
        "--recipient",
        help="XMPP recipient of SMS (multiple JIDs possible)",
        action="append",
    )
    ap.add_argument(
        "-m",
        "--modem-filter",
        help="filter modems, if there are more than one, e.g."
        " DeviceIdentifier:44556677[..]ccddeeff,"
        " EquipmentIdentifier:887766545667788,"
        " Manufacturer:A_Company, or Model:ABCD",
    )
    ap.add_argument(
        "-s",
        "--secret-file",
        default="/etc/sms4you/xmpp_component_password.txt",
        help="file with the component password",
    )
    ap.add_argument(
        "-x", "--xmpp-server", default="localhost", help="XMPP server hosting the component"
    )
    return ap.parse_args()


if __name__ == "__main__":
    args = getargs()
    logger = init_logger("sms4you", args.debug)
    loop = asyncio.get_event_loop()
    component = SMSComponent(
        args.allowed or [],
        args.component,
        args.port,
        args.recipient or [],
        args.secret_file,
        args.xmpp_server,
        loop,
        logger,
    )
    sms_task = loop.create_task(receive_sms(loop, component, args.modem_filter, logger))

    sdnotify.SystemdNotifier().notify("READY=1")

    loop.run_forever()
    loop.close()
