#!/usr/bin/env python3

# Libervia: an XMPP client
# Copyright (C) 2009-2026 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/>.

"""Script launching Libervia backend"""

import sys
import os
import argparse
from pathlib import Path

# We want to install asyncreactor before any import from twisted or Libervia to be sure
# that the right reactor is used.
import asyncio
from twisted.internet import asyncioreactor

# FIXME: In Python 3.14+, asyncio.get_event_loop() raises a RuntimeError and no longer
#   implicitly creates a new loop. This behavior was making the backend launch fail. So to
#   work around this, we create a new loop here, before installing asyncioreactor. A fix
#   is present in a non-released version of Twisted (commit ef6160a). Once the new version
#   is realeased and used in Libervia, we can remove this workaround.
try:
    asyncio.get_event_loop()
except RuntimeError:
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)

asyncioreactor.install()

from libervia.backend.core.constants import Const as C
from libervia.backend.tools.config import parse_main_conf
from twisted.application import app
from twisted.python import usage


class LiberviaLogger(app.AppLogger):
    def start(self, application):
        # logging is initialised by libervia.baceknd.core.log_config via the Twisted
        # plugin, nothing to do here
        self._initialLog()

    def stop(self):
        pass


class Launcher:
    APP_NAME = C.APP_NAME
    APP_NAME_FILE = C.APP_NAME_FILE

    @property
    def NOT_RUNNING_MSG(self):
        return f"{self.APP_NAME} is *NOT* running"

    def __init__(self):
        try:
            self.config = parse_main_conf(log_filenames=True)
        except Exception as e:
            print(rf"/!\ Can't read main config! {e}")
            sys.exit(1)

    def cmd_no_subparser(self, args):
        """Command launched by default"""
        args.extra_args = []
        self.cmd_background(args)

    def cmd_background(self, args):
        self.run_twistd(args)

    def cmd_foreground(self, args):
        self.run_twistd(args, twistd_opts=["--nodaemon"])

    def cmd_debug(self, args):
        self.run_twistd(args, twistd_opts=["--debug"])

    def cmd_stop(self, args):
        import signal
        import time

        pid_file = self.get_pid_file()
        if not pid_file.is_file():
            print(self.NOT_RUNNING_MSG)
            sys.exit(0)
        try:
            pid = int(pid_file.read_text())
        except Exception as e:
            print(f"Can't read PID file at {pid_file}: {e}")
            # we use the same exit code as DATA_ERROR in CLI frontend
            sys.exit(17)
        print(f"Terminating {self.APP_NAME}…")
        os.kill(pid, signal.SIGTERM)
        kill_started = time.time()
        state = "init"
        import errno

        while True:
            try:
                os.kill(pid, 0)
            except OSError as e:
                if e.errno == errno.ESRCH:
                    break
                elif e.errno == errno.EPERM:
                    print(
                        f"Can't kill {self.APP_NAME}, the process is owned by an other user",
                        file=sys.stderr,
                    )
                    sys.exit(18)
                else:
                    raise e
            time.sleep(0.2)
            now = time.time()
            if state == "init" and now - kill_started > 5:
                if state == "init":
                    state = "waiting"
                    print(f"Still waiting for {self.APP_NAME} to be terminated…")
            elif state == "waiting" and now - kill_started > 10:
                state == "killing"
                print("Waiting for too long, we kill the process")
                os.kill(pid, signal.SIGKILL)
                sys.exit(1)

        sys.exit(0)

    def cmd_status(self, args):
        pid_file = self.get_pid_file()
        if pid_file.is_file():
            import errno

            try:
                pid = int(pid_file.read_text())
            except Exception as e:
                print(f"Can't read PID file at {pid_file}: {e}")
                # we use the same exit code as DATA_ERROR in CLI frontend
                sys.exit(17)
            # we check if there is a process
            # inspired by https://stackoverflow.com/a/568285 and https://stackoverflow.com/a/6940314
            try:
                os.kill(pid, 0)
            except OSError as e:
                if e.errno == errno.ESRCH:
                    running = False
                elif e.errno == errno.EPERM:
                    print("Process {pid} is run by an other user")
                    running = True
            else:
                running = True

            if running:
                print(f"{self.APP_NAME} is running (pid: {pid})")
                sys.exit(0)
            else:
                print(
                    f"{self.NOT_RUNNING_MSG}, but a pid file is present (bad exit ?): {pid_file}"
                )
                sys.exit(2)
        else:
            print(self.NOT_RUNNING_MSG)
            sys.exit(1)

    def parse_args(self):
        parser = argparse.ArgumentParser(description=f"Launch {self.APP_NAME} backend")
        parser.set_defaults(cmd=self.cmd_no_subparser)
        subparsers = parser.add_subparsers()
        extra_help = f"arguments to pass to {self.APP_NAME} service"

        bg_parser = subparsers.add_parser(
            "background",
            aliases=["bg"],
            help=f"run {self.APP_NAME} backend in background (as a daemon)",
        )
        bg_parser.add_argument("extra_args", nargs=argparse.REMAINDER, help=extra_help)
        bg_parser.set_defaults(cmd=self.cmd_background)

        fg_parser = subparsers.add_parser(
            "foreground",
            aliases=["fg"],
            help=f"run {self.APP_NAME} backend in foreground",
        )
        fg_parser.add_argument("extra_args", nargs=argparse.REMAINDER, help=extra_help)
        fg_parser.set_defaults(cmd=self.cmd_foreground)

        dbg_parser = subparsers.add_parser(
            "debug", aliases=["dbg"], help=f"run {self.APP_NAME} backend in debug mode"
        )
        dbg_parser.add_argument("extra_args", nargs=argparse.REMAINDER, help=extra_help)
        dbg_parser.set_defaults(cmd=self.cmd_debug)

        stop_parser = subparsers.add_parser(
            "stop", help=f"stop running {self.APP_NAME} backend"
        )
        stop_parser.set_defaults(cmd=self.cmd_stop)

        status_parser = subparsers.add_parser(
            "status", help=f"indicate if {self.APP_NAME} backend is running"
        )
        status_parser.set_defaults(cmd=self.cmd_status)

        return parser.parse_args()

    def get_pid_file(self) -> Path:
        return self.config.common.pid_dir / f"{self.APP_NAME_FILE}.pid"

    def wait_for_service(
        self, service_host: str, service_port: int, timeout: int, service_name: str
    ) -> None:
        """Waits for a network service to become available.

        @param service_host: The hostname or IP address of the service.
        @param service_port: The port number of the service.
        @param timeout: The maximum number of seconds to wait for the service.
        @param service_name: The name of the service.

        @raise TimeoutError: If the service is not available within the specified timeout.
        """
        import socket
        import time

        start_time = time.time()
        wait_interval = 5

        while True:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
                sock.settimeout(1)
                try:
                    sock.connect((service_host, service_port))
                    return
                except socket.error:
                    elapsed_time = time.time() - start_time
                    if elapsed_time % wait_interval < 1:
                        print(f"Waiting for {service_name}…")
                    if elapsed_time > timeout:
                        raise TimeoutError(
                            f"{service_name} on {service_host}:{service_port} not "
                            f"available after {timeout} seconds."
                        )
                    time.sleep(1)

    def run_twistd(self, args, twistd_opts=None):
        """Run twistd settings options with args"""
        from twisted.python.runtime import platformType

        if platformType == "win32":
            from twisted.scripts._twistw import (
                ServerOptions,
                WindowsApplicationRunner as app_runner,
            )
        else:
            from twisted.scripts._twistd_unix import (
                ServerOptions,
                UnixApplicationRunner as app_runner,
            )

        app_runner.loggerFactory = LiberviaLogger
        server_options = ServerOptions()

        # wait for a service (e.g. XMPP server)
        wait_for_service_value = self.config.common.init_wait_for_service

        if wait_for_service_value is not None:
            try:
                # Syntax: [ipv6_address]:port[:timeout][:service_name]
                # or hostname:port[:timeout][:service_name]
                parts = wait_for_service_value.split(":")
                if parts[0] and parts[0][0] == "[" and parts[0][-1] == "]":
                    # IPv6 address
                    host = parts[0][1:-1]
                else:
                    # Hostname or IPv4
                    host = parts[0]

                port = int(parts[1])

                # Defaults
                timeout = 60
                service_name = "service"

                if len(parts) > 2:
                    timeout_part = parts[2]
                    # Check if timeout is skipped (double colon for service_name)
                    if timeout_part:
                        timeout = int(timeout_part)
                    if len(parts) > 3:
                        service_name = parts[3]
            except (ValueError, IndexError):
                raise ValueError(
                    f'Invalid "init_wait_for_service" value: {wait_for_service_value!r}'
                )
            else:
                self.wait_for_service(host, port, timeout, service_name)

        pid_file = self.get_pid_file()
        log_dir = self.config.common.log_dir
        log_file = log_dir / f"{self.APP_NAME_FILE}.log"
        server_opts = [
            "--no_save",
            "--pidfile",
            str(pid_file),
            "--logfile",
            str(log_file),
        ]
        if twistd_opts is not None:
            server_opts.extend(twistd_opts)
        server_opts.append(self.APP_NAME_FILE)
        if args.extra_args:
            try:
                args.extra_args.remove("--")
            except ValueError:
                pass
            server_opts.extend(args.extra_args)
        try:
            server_options.parseOptions(server_opts)
        except usage.error as ue:
            print(server_options)
            print("%s: %s" % (sys.argv[0], ue))
            sys.exit(1)
        else:
            runner = app_runner(server_options)
            runner.run()
            if runner._exitSignal is not None:
                app._exitWithSignal(runner._exitSignal)
            # FIXME: The loop is not closing correctly as soon as
            #   libervia.backend.tools.utils.aio decoractor is used. Asyncio loop doesn't
            #   seem to have any task left, and reactor is stopped, but there is still
            #   something preventing proper exit. `os._exit` is not great, but currently
            #   the way to actually close the backend in this case. This needs further
            #   investigation.
            try:
                os._exit(app._exit_code)
            except AttributeError:
                os._exit(C.EXIT_ERROR)

    @classmethod
    def run(cls):
        args = cls().parse_args()
        args.cmd(args)


if __name__ == "__main__":
    Launcher.run()
