#
# Core APRS Client
# Author: Joerg Schultze-Lutter, 2025
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#

import sys
import signal
import time
import atexit
import os
from functools import partial
import logging
from pprint import pformat
from collections.abc import Callable
from typing import Dict, Any, Mapping
import threading
import copy
from types import MappingProxyType

from . import client_shared
from .client_utils import (
    signal_term_handler,
    check_and_create_data_directory,
    client_exception_handler,
    handle_exception,
    check_for_default_config,
    finalize_pretty_aprs_messages,
    make_pretty_aprs_messages,
    generate_apprise_message,
)
from .client_configuration import load_config, program_config
from .client_aprsobject import APRSISObject
from .client_message_counter import APRSMessageCounter
from .client_expdict import create_expiring_dict
from .client_aprs_communication import (
    aprs_callback,
    init_scheduler_jobs,
    remove_scheduler,
)
from .client_logger import logger, update_logging_level
from .client_return_codes import CoreAprsClientInputParserStatus


class CoreAprsClient:
    config_file: str
    log_level: int
    input_parser: Callable[..., Any]
    output_generator: Callable[..., Any]
    post_processor: Callable[..., Any] | None

    def __init__(
        self,
        config_file: str,
        input_parser: Callable[..., Any],
        output_generator: Callable[..., Any],
        post_processor: Callable[..., Any] | None = None,
        log_level: int = logging.INFO,
    ):
        """
        Class initialization

        Parameters
        ==========
        config_file: str
            'core-aprs-client's config file name, see
            see https://github.com/joergschultzelutter/core-aprs-client/blob/master/docs/configuration.md
        input_parser: Callable[..., Any]
            Name of the user's input parser function, see
            https://github.com/joergschultzelutter/core-aprs-client/blob/master/docs/framework_usage.md
        output_generator: Callable[..., Any]
            Name of the user's output generator function, see
            https://github.com/joergschultzelutter/core-aprs-client/blob/master/docs/framework_usage.md
        post_processor: Callable[..., Any] | None
            (optional); Name of the user's post processor function, see
            https://github.com/joergschultzelutter/core-aprs-client/blob/master/docs/framework_usage.md
        log_level: int
            Log level from Python's 'logging' module
            https://docs.python.org/3/library/logging.html#logging-levels

        Returns
        =======

        """
        self.config_file = config_file
        self.input_parser = input_parser
        self.output_generator = output_generator
        self.post_processor = post_processor
        self.log_level = log_level
        self._dynamic_aprs_bulletins: Dict[str, Any] = {}
        self._lock = threading.Lock()

        # Prepare the config file handler
        # Check if the config file exists
        if not os.path.isfile(config_file):
            raise FileNotFoundError(f"Configuration file '{config_file}' not found")

        # load the program config from our external config file
        load_config(config_file=self.config_file)
        if len(program_config) == 0:
            logger.error(
                msg="Program config file is empty or contains an error; exiting"
            )
            sys.exit(0)

        # And check if the user still runs with the default config
        # Currently, we do not abort the code but only issue an error to the user
        check_for_default_config()

        # Finally, create the MappingProxyType copy of the configuration
        # so that we can expose it to the user, if requested.
        self._config_data = MappingProxyType(program_config.copy())

        # Update the log level (if needed)
        update_logging_level(logging_level=self.log_level)

    def activate_client(self, **kwargs):
        """
        This function is responsible for setting up the communication
        with APRS-IS. It reads the configuration file and establishes
        the network communication with the APRS-IS server. Finally, the
        aprslib's callback function gets triggered.

        Parameters
        ==========
        **kwargs: dict
            Potential user-defined parameters; will get passed along to
            both input parser and output generator

        Returns
        =======

        """

        # Install our custom exception handler, thus allowing us to signal the
        # user who hosts this bot with a message whenever the program is prone to crash
        # OR has ended. In any case, we will then send the file to the host
        #
        # if you are not interested in a post-mortem call stack, remove the following
        # two lines
        logger.debug(msg=f"Activating bot exception handler")
        atexit.register(client_exception_handler)
        sys.excepthook = handle_exception

        # Check whether the data directory exists
        success = check_and_create_data_directory(
            root_path_name=os.path.abspath(os.getcwd()),
            relative_path_name=program_config["coac_data_storage"][
                "aprs_data_directory"
            ],
        )
        if not success:
            sys.exit(0)

        #
        # Read the message counter
        client_shared.aprs_message_counter = APRSMessageCounter(
            file_name=program_config["coac_data_storage"][
                "aprs_message_counter_file_name"
            ]
        )

        # Create the APRS-IS dupe message cache
        client_shared.aprs_message_cache = create_expiring_dict(
            max_len=program_config["coac_dupe_detection"]["msg_cache_max_entries"],
            max_age_seconds=program_config["coac_dupe_detection"][
                "msg_cache_time_to_live"
            ],
        )

        # Register the SIGTERM handler; this will allow a safe shutdown of the program
        logger.debug(msg="Registering SIGTERM handler for safe shutdown...")
        signal.signal(signal.SIGTERM, signal_term_handler)

        # Create the future aprs_scheduler variable
        aprs_scheduler = None

        # Enter the 'eternal' receive loop
        try:
            while True:
                client_shared.AIS = APRSISObject(
                    aprsis_callsign=program_config["coac_client_config"][
                        "aprsis_callsign"
                    ],
                    aprsis_passwd=str(
                        program_config["coac_network_config"]["aprsis_passcode"]
                    ),
                    aprsis_host=program_config["coac_network_config"][
                        "aprsis_server_name"
                    ],
                    aprsis_port=program_config["coac_network_config"][
                        "aprsis_server_port"
                    ],
                    aprsis_filter=program_config["coac_network_config"][
                        "aprsis_server_filter"
                    ],
                )

                # Connect to APRS-IS
                logger.debug(msg="Establishing connection to APRS-IS...")
                client_shared.AIS.ais_connect()

                # Are we connected?
                if client_shared.AIS.ais_is_connected():
                    logger.debug(msg="Established the connection to APRS-IS")

                    # Install the APRS-IS beacon / bulletin schedulers if
                    # activated in the program's configuration file
                    # Otherwise, this field's value will be 'None'
                    aprs_scheduler = init_scheduler_jobs(class_instance=self)

                    # create the partial object for our callback
                    enhanced_callback = partial(
                        aprs_callback,
                        instance=self,
                        parser=self.input_parser,
                        generator=self.output_generator,
                        postproc=self.post_processor,
                        **kwargs,
                    )

                    #
                    # We are now ready to initiate the actual processing
                    # Start the consumer thread
                    logger.info(msg="Starting APRS-IS callback consumer")
                    client_shared.AIS.ais_start_consumer(enhanced_callback)

                    #
                    # We have left the callback, let's clean up a few things
                    logger.debug(msg="Have left the callback consumer")
                    #
                    # First, stop all schedulers. Then remove the associated jobs
                    # This will prevent the beacon/bulletin processes from sending out
                    # messages to APRS_IS
                    # Note that the scheduler might not be active - its existence depends
                    # on the user's configuration file settings.
                    if aprs_scheduler:
                        remove_scheduler(aprs_scheduler=aprs_scheduler)
                    aprs_scheduler = None

                    # close the connection to APRS-IS
                    logger.debug(msg="Closing APRS connection to APRS-IS")
                    client_shared.AIS.ais_close()
                    client_shared.AIS = None
                else:
                    logger.debug(msg="Cannot re-establish connection to APRS-IS")

                # Write current number of packets to disk
                client_shared.aprs_message_counter.write_counter()

                # Enter sleep mode and then restart the loop
                logger.debug(msg=f"Sleeping ...")
                time.sleep(program_config["coac_message_delay"]["packet_delay_message"])

        except (KeyboardInterrupt, SystemExit):
            # Tell the user that we are about to terminate our work
            logger.debug(
                msg="KeyboardInterrupt or SystemExit in progress; shutting down ..."
            )

            # write most recent APRS message counter to disk
            client_shared.aprs_message_counter.write_counter()

            # Shutdown (and remove) the scheduler if it still exists
            if aprs_scheduler:
                remove_scheduler(aprs_scheduler=aprs_scheduler)

            # Close APRS-IS connection whereas still present
            if client_shared.AIS.ais_is_connected():
                client_shared.AIS.ais_close()

    def dryrun_testcall(self, message_text: str, from_callsign: str, **kwargs):
        """
        This function can be used for 100% offline testing. It does trigger
        the standard input parser and output generator.

        Parameters
        ==========
        message_text: str
            The (simulated) APRS input message; sent to us by "from_callsign"
        from_callsign: str
            The callsign that the message was sent from
        **kwargs: dict
            Potential user-defined parameters; will get passed along to
            both input parser and output generator

        Returns
        =======
        none
        """
        logger.info("Activating dryrun testcall...")

        # load the program config from our external config file
        load_config(config_file=self.config_file)
        if len(program_config) == 0:
            logger.error(
                msg="Program config file is empty or contains an error; exiting"
            )
            sys.exit(0)

        # Register the on_exit function to be called on program exit
        atexit.register(client_exception_handler)

        # Set up the exception handler to catch unhandled exceptions
        sys.excepthook = handle_exception

        logger.info(
            msg=f"parsing message '{message_text}' for callsign '{from_callsign}'"
        )

        retcode, input_parser_error_message, response_parameters = self.input_parser(
            instance=self,
            aprs_message=message_text,
            from_callsign=from_callsign,
            **kwargs,
        )

        # this is our potential postprocessor input object
        # If its future value is not 'None' AND a post processor has been
        # set up for the class' object instance, then we try to run the
        # given post processor AFTER the output processor's message has been sent
        # to the user via APRS
        postproc_data = None

        logger.info(msg="Parsed message:")
        logger.info(msg=pformat(response_parameters))
        logger.info(msg=f"return code: {retcode}")
        match retcode:
            case CoreAprsClientInputParserStatus.PARSE_OK:

                logger.info("Running Output Processor build ...")

                # (Try to) build the outgoing message string
                success, output_message_string, postproc_data = self.output_generator(
                    instance=self,
                    input_parser_response_object=response_parameters,
                    **kwargs,
                )
                logger.info(msg=f"Output Processor response={success}, message:")
                logger.info(msg=output_message_string)

                # Generate the outgoing content, if successful
                if success:
                    logger.info(
                        "Output processor status successful; building outgoing messages ..."
                    )
                    # Convert to pretty APRS messaging
                    output_message = make_pretty_aprs_messages(
                        message_to_add=output_message_string
                    )

                    # And finalize the output message, if needed
                    output_message = finalize_pretty_aprs_messages(
                        mylistarray=output_message
                    )
                else:
                    logger.info("Output processor status unsuccessful")
                    # This code branch should never be reached unless there is a
                    # discrepancy between the action determined by the input parser
                    # and the responsive counter-action in the output processor
                    output_message = make_pretty_aprs_messages(
                        message_to_add=program_config["coac_client_config"][
                            "aprs_input_parser_default_error_message"
                        ],
                    )
                logger.info(msg=pformat(output_message))

                if self.post_processor and postproc_data:
                    logger.debug(msg="Activating postprocessor...")
                    success = self.post_processor(
                        instance=self,
                        postprocessor_input_object=postproc_data,
                        **kwargs,
                    )
                    logger.debug(msg=f"postprocessor response='{success}'")

            case CoreAprsClientInputParserStatus.PARSE_ERROR | _:
                logger.error(
                    msg="input_parser_error_message = {input_parser_error_message}"
                )
                # Dump the human readable message to the user if we have one
                if input_parser_error_message:
                    output_message = make_pretty_aprs_messages(
                        message_to_add=f"{input_parser_error_message}",
                    )
                # If not, just dump the link to the instructions
                else:
                    output_message = make_pretty_aprs_messages(
                        message_to_add=program_config["coac_client_config"][
                            "aprs_input_parser_default_error_message"
                        ],
                    )
                # Ultimately, finalize the outgoing message(s) and add the message
                # numbers if the user has requested this in his configuration
                # settings
                output_message = finalize_pretty_aprs_messages(
                    mylistarray=output_message
                )

                logger.info(pformat(output_message))
                logger.info(msg=pformat(response_parameters))

    @property
    def dynamic_aprs_bulletins(self) -> Mapping[str, Any]:
        """
        'getter' for the dynamic aprs bulletins

        Parameters
        ==========

        Returns
        =======
        dynamic_aprs_bulletins: Mapping[str, Any]
            immutable copy of the dynamic aprs bulletins' 'dict' object
        """

        with self._lock:
            return MappingProxyType(self._dynamic_aprs_bulletins)

    @dynamic_aprs_bulletins.setter
    def dynamic_aprs_bulletins(self, new_dict: Dict[str, Any]) -> None:
        """
        'setter' for the dynamic aprs bulletins

        Parameters
        ==========
        new_dict: Mapping[str, Any]
            dynamic aprs bulletins' 'dict' object, used as a foundation
            for the class' dict object

        Returns
        =======

        """
        with self._lock:
            self._dynamic_aprs_bulletins = copy.deepcopy(new_dict)

    @property
    def config_data(self) -> Mapping[str, Any]:
        """
        'getter' for the class' config data

        Parameters
        ==========

        Returns
        =======
        config_data: Mapping[str, Any]
            immutable copy of the class' config data 'dict' object
        """
        with self._lock:
            return self._config_data

    def send_apprise_message(
        self,
        msg_header: str,
        msg_body: str,
        msg_attachment: str | None = None,
        apprise_cfg_file: str | None = None,
    ) -> bool:
        """
        This function is uses the utility function's Apprise messaging
        code in order to send messages via Apprise. For the Apprise config file,
        the user can either specify a separate file OR omits the file name; in that case,
        core-aprs-client will take the Apprise config file name from the framework's config
        file

        Parameters
        ==========
        msg_header: str
        The message header

        msg_body: str
        The message body

        msg_attachment: str|None
        Optional. The message attachment (as a reference to an external file name) or 'None' for no attachment

        apprise_cfg_file: str|None
        Path to an Apprise config file. If set to 'None', we try to use the framework's config file, see
        www.github.com/joergschultzelutter/core-aprs-client/blob/apprise-messaging-method/docs/configuration_subsections/config_crash_handler.md

        Returns
        =======
        success: bool
        The message was sent successfully
        """

        # Check if the user has provided us with an Apprise config file name
        # If not, pick the one that we have in our configuration file
        #
        # Note that there is no need for us to check if the file exists or not: this part
        # will be taken care of by the message generator itself
        if not apprise_cfg_file:
            logger.debug(
                msg="No apprise_cfg_file specified; using default from core-aprs-client's configuration file"
            )
            with self._lock:
                pgm_config = self._config_data
            apprise_cfg_file = pgm_config["coac_crash_handler"]["apprise_config_file"]

        return generate_apprise_message(
            message_header=msg_header,
            message_body=msg_body,
            message_attachment=msg_attachment,
            apprise_config_file=apprise_cfg_file,
        )

    @staticmethod
    def log_info(msg: str) -> None:
        logger.info(msg)

    @staticmethod
    def log_error(msg: str) -> None:
        logger.error(msg)

    @staticmethod
    def log_debug(msg: str) -> None:
        logger.debug(msg)

    @staticmethod
    def log_warning(msg: str) -> None:
        logger.warning(msg)
