"""
Use the console logger as follows:
    from forcen_public_utils.loggers.console_logger import ConsoleLogger
    logger = ConsoleLogger()

Motivation:

A singleton logger which displays outputs to the console, and optionally to a file (including the
capability for file rotations at certain file sizes).

Configuration:

The console logger is a singleton, so it can only be configured once.
There are two configuration options, which are mutually exclusive.
1. Configure from environment variables stored in the project's .env file.
2. Configure manually using the ConsoleLoggerConfig class.

Note that configuring the ConsoleLogger manually takes precedence over configuring with
environment variables.

See the FORCEN_CONSOLE_LOGGER_ENV dict for the environment variable keys to use.

Recommended Usage:

Set up the console logger in your main file as follows:
```python
    # <main.py>
    import forcen_public_utils.loggers.console_logger as cl

    if __name__ == '__main__':
        # Option 1: Set up the logger using environment variables by default,
        # or with defaults if none are found.
        ConsoleLogger()

        # Option 2: Set up the logger manually
        params = cl.ConsoleLoggerConfig(...)
        cl.ConsoleLogger(params)

        # ... rest of the code ...
```

Then use the logger inside any method or package which needs logging:
```python
    # <some_python_file.py>
    from forcen_public_utils.loggers.console_logger as cl

    def some_method_with_logging():
        logger = cl.ConsoleLogger.get() # Get the pre-configured logger
        logger.log(cl.LoggerLevel.<LEVEL>, "your message here")
```
"""

from __future__ import annotations

import dataclasses
import enum
import os
import pathlib
import sys
from functools import total_ordering
from typing import Dict, Optional, TextIO, Tuple

import loguru

import forcen_public_utils.loggers.log_path_utils as log_paths
import forcen_public_utils.loggers.logging_common as common
from forcen_public_utils.singleton import Singleton

# Dict to retrieve keys for env variables, and their default values if not provided in the env file.
FORCEN_CONSOLE_LOGGER_ENV: Dict[str, Tuple[str, str]] = {
    "level": ("FORCEN_CONSOLE_LOGGER_LEVEL", "INFO"),
    "folder": ("FORCEN_CONSOLE_LOGGER_FOLDER", str(log_paths.get_forcen_tmp_root())),
    "file": ("FORCEN_CONSOLE_LOGGER_FILENAME", "console.log"),
    "rotation": ("FORCEN_CONSOLE_LOGGER_ROTATION", "0"),
}


_VERBOSE_LOG_FORMAT = (
    "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green>"
    " | "
    "<yellow>{thread.name}</yellow>"
    " | "
    " <magenta> {process.name} (PID-{process.id})</magenta>"
    " | "
    "<level>{level: <8}</level>"
    " | "
    "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan>"
    " | "
    "<level>{message}</level>"
)

_TERSE_LOG_FORMAT = (
    "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green>"
    " | "
    "<yellow>{thread.name}</yellow>"
    " | "
    " <magenta>PID-{process.id}</magenta>"
    " | "
    "<cyan>{file}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan>"
    " | "
    "<level>{message}</level>"
)


@total_ordering
class LoggerLevel(enum.Enum):
    """Enumeration of all loggable levels.

    Levels are ranked in the following order of priority:
    TRACE < DEBUG < INFO < SUCCESS < WARNING < ERROR < CRITICAL
    """

    TRACE = enum.auto()
    DEBUG = enum.auto()
    INFO = enum.auto()
    SUCCESS = enum.auto()
    WARNING = enum.auto()
    ERROR = enum.auto()
    CRITICAL = enum.auto()

    def __lt__(self, other: LoggerLevel) -> bool:
        if self.__class__ is other.__class__:
            return self.value < other.value
        return NotImplemented

    def __str__(self) -> str:
        return str(self.name)


@dataclasses.dataclass(frozen=True)
class ConsoleLoggerParams:
    """Dataclass of configuration parameters for the console logger.

    Args:
            logger_config (logging_common.LoggerConfig): A logging configuration
            min_logging_level (LoggerLevel, optional): set logger to only print messages with level
                above this value. Defaults to LoggerLevel.INFO.
            output_sink (TextIO): location to which the logger will print.
                Defaults to sys.stderr.
            log_to_file: when true, the console logger will save outputs to the file provided
                in the configuration.
            verbose: when true, the console logger will output verbose formatted logs in the format
                    date | ThreadID | ProcessName (ProcessID) | LoggingLevel | module.function.line | message.
                When false, the console logger will output terse formatted logs in the format
                    date | ThreadID | ProcessID | file.function.line | message
                Defaults to true.

    """

    logger_config: common.LoggerConfig
    min_logging_level: LoggerLevel = LoggerLevel[FORCEN_CONSOLE_LOGGER_ENV["level"][1].upper()]
    output_sink: TextIO = sys.stderr
    log_to_file: bool = True
    verbose: bool = True


def get_default_console_logger_config(app_name: str = common.LOGGING_APP) -> common.LoggerConfig:
    """Construct a default logger config object for a given application.

    Args:
        app_name (str, optional): Defaults to `forcen_public_utils.loggers.logging_common.LOGGING_APP`

    Returns:
        common.LoggerConfig
    """
    return common.LoggerConfig(
        app_name=app_name,
        log_root=pathlib.Path(FORCEN_CONSOLE_LOGGER_ENV["folder"][1]),
        log_file_name=FORCEN_CONSOLE_LOGGER_ENV["file"][1],
        max_file_size=int(FORCEN_CONSOLE_LOGGER_ENV["rotation"][1]),
    )


def get_default_console_logger_params(app_name: Optional[str] = None) -> ConsoleLoggerParams:
    """Get the default console logger configuration parameters for a given app.

    Args:
        app_name (str, optional): the app which is running this logger.

    Returns:
        ConsoleLoggerParams
    """
    if app_name is None:
        logger_config = get_default_console_logger_config()
    else:
        logger_config = get_default_console_logger_config(app_name)
    return ConsoleLoggerParams(logger_config=logger_config)


def get_console_logger_params_from_env() -> ConsoleLoggerParams:
    """Get a valid configuration for the console logger from environment variables.

    Environment variables which are not assigned are set to their default values.

    Raises:
        ValueError: If env variable for minimum level is set, but is not a valid level.
        ValueError: If env variable for max file size is set but is not a positive integer.

    Returns:
        ConsoleLoggerConfig
    """
    # First, get information about the logger settings from the .env file.
    # Use defaults if they weren't provided.
    # Note that paths may be empty strings and still resolve, so we need to handle that later
    # in this function.
    env_level = os.environ.get(
        FORCEN_CONSOLE_LOGGER_ENV["level"][0], FORCEN_CONSOLE_LOGGER_ENV["level"][1]
    )
    env_folder = os.environ.get(
        FORCEN_CONSOLE_LOGGER_ENV["folder"][0], FORCEN_CONSOLE_LOGGER_ENV["folder"][1]
    )
    env_file = os.environ.get(
        FORCEN_CONSOLE_LOGGER_ENV["file"][0], FORCEN_CONSOLE_LOGGER_ENV["file"][1]
    )
    env_rotation = os.environ.get(
        FORCEN_CONSOLE_LOGGER_ENV["rotation"][0], FORCEN_CONSOLE_LOGGER_ENV["rotation"][1]
    )

    # Convert logger level if possible. This is a required parameter, so throw an error if the
    # user provided a value that is not parseable.
    try:
        level = LoggerLevel[env_level.upper()]
    except KeyError as e:
        raise ValueError(
            (
                f"ConsoleLogger: Env variable for minimum log level has value {env_level}, "
                " which is not a valid logger level."
                " Valid options are: "
                ", ".join([lev.name for lev in LoggerLevel])
            )
        ) from e

    # Convert strings to paths. If the folder is an empty string, then we do not log to file.
    # If the file is an empty string, then we also do not log to file.
    log_to_file = bool(env_folder and env_file)
    folder = (
        pathlib.Path(env_folder)
        if env_folder
        else pathlib.Path(FORCEN_CONSOLE_LOGGER_ENV["folder"][1])
    )
    file = env_file if env_file else FORCEN_CONSOLE_LOGGER_ENV["file"][1]

    # Convert rotation env variable to an integer. If the rotation env variable is not a positive
    # integer we will simply raise a warning to the user
    try:
        rotation_int = int(env_rotation)
    except ValueError as e:
        raise ValueError(
            f"ConsoleLogger ROTATION env variable {env_rotation} must be a positive integer."
        ) from e
    rotation = rotation_int if rotation_int > 0 else None

    return ConsoleLoggerParams(
        logger_config=common.LoggerConfig(
            app_name="console_logger",
            log_file_name=file,
            log_root=folder,
            max_file_size=rotation,
        ),
        min_logging_level=level,
        log_to_file=log_to_file,
    )


class ConsoleLogger(metaclass=Singleton):
    """
    The Forcen console logger.

    This logger displays information to the console, and simultaneously saves all output to a file.
    Each logged line displays the following information:
    * Timestamp
    * Process or Thread ID (if in a thread)
    * Logging level
    * File and line number where logger was called
    * The log message

    This logger is customizable, and includes the following customization options:
    1. The minimum logging level: all logging outputs above this level are displayed to console.
        * Defaults to INFO.
        * Use the environment variable FORCEN_CONSOLE_LOGGER_LEVEL.
    2. The file path where outputs are saved. Optional.
        * Use the environment variable FORCEN_CONSOLE_LOGGER_FOLDER to set the root folder for the logs
        * Use the environment variable FORCEN_CONSOLE_LOGGER_FILENAME to set the name of the file,
            including the extension.
        * If either of these environment variables are set to blank, the console logger will
            not log to file.
    3. The maximum file size, in bytes, after which files are rotated. Optional.
        * Log files are rotated with the name '<filename>.<datetime>.<ext>'
        * Use the environment variable FORCEN_CONSOLE_LOGGER_ROTATION.
    """

    def __init__(self, params: Optional[ConsoleLoggerParams] = None) -> None:
        """Construct the logger.

        If no configuration parameters are given, ConsoleLogger will be try to construct
        itself from environment variables. If environment variables are not found,
        the default configuration will be used.
        Args:
            params (ConsoleLoggerParams, optional): the initial parameters for the logger.
        """
        # First, delete the default loguru output, since we cannot edit it.
        # Note that any new loggers will always have id > 0, so calling remove(0) will
        # only ever remove the default logger.
        try:
            loguru.logger.remove(0)
        except ValueError:
            pass

        # Define attributes that we need in the future
        self._console_logger = (
            loguru.logger
        )  # this will be replaced by the end of __init__, but pylint complains if it's None
        self.console_logger_id: Optional[int] = None
        self._file_logger_id: Optional[int] = None

        self.logger_params = params if params else get_console_logger_params_from_env()
        self._unsafely_reconfigure_singleton(self.logger_params)

    def _unsafely_reconfigure_singleton(self, params: ConsoleLoggerParams) -> None:
        """DANGER: Configure the logger.

        This is a helper method which allows us to unit test this Singleton class.
        This is only intended to be used once by __init__ or by unit tests.
        This method erases handlers to file and console logging, and is not thread safe.

        Args:
        config (ConsoleLoggerParams): the configuration settings.
        """

        # We first define a filter which makes the console logger only receive messages that
        # contain a special key.
        def has_console(record) -> bool:
            return "is_console" in record["extra"]

        # If we have an existing console logger, get rid of it.
        if self.console_logger_id is not None:
            loguru.logger.remove(self.console_logger_id)

        # Set up the format for the logger
        # TIMESTAMP | Thread | PID | LogLevel | file:method:line | Message
        log_format = _VERBOSE_LOG_FORMAT if params.verbose else _TERSE_LOG_FORMAT

        # Now add a new handler which prints to the console.
        # This handler uses the filter so it only displays logs that contain the special key.
        self.console_logger_id = loguru.logger.add(
            sink=params.output_sink,
            level=str(params.min_logging_level),
            filter=has_console,
            format=log_format,
            enqueue=True,  # make logging thread-safe
        )
        # If we have an existing file logger id, remove that logger to avoid duplication
        if self._file_logger_id is not None:
            loguru.logger.remove(self._file_logger_id)
            self._file_logger_id = None

        if params.log_to_file:
            # In the rare case where someone passes in a file size that is not a valid
            # number of bytes, then we ignore it.
            max_file_size = params.logger_config.max_file_size
            if max_file_size is not None and max_file_size <= 0:
                max_file_size = None

            # Create a logger which prints to the file.
            self._file_logger_id = loguru.logger.add(
                sink=params.logger_config.log_file_path,
                level=str(params.min_logging_level),
                filter=has_console,
                format=log_format,
                rotation=max_file_size,
                enqueue=True,  # make logging to file thread-safe
            )

        # Now wrap the logger object so that it contains the special key whenever we log.
        self._console_logger = loguru.logger.bind(is_console=True)

    def complete(self) -> None:
        """Await all logs in the multiprocessing-safe queue to be processed."""
        self._console_logger.complete()

    def log(self, level: LoggerLevel, message: str, depth: int = 1) -> ConsoleLogger:
        """Log a message at the given logger level.

        This is done through a multiprocessing-safe queue, so it is non-blocking and async.
        You can make this synchronous by chaining calls with .complete(), as in:
        ConsoleLogger().log(...).complete()

        Args:
            level (LoggerLevel): The level of the log
            message (str): The message to display

        Returns:
            ConsoleLogger: self, so one can chain calls with .complete() to make this
            a synchronous method.
        """
        # Wrap the built-in log() method with an option of depth >= 1, which
        # foroces the log to display as coming from the file where
        # this method was called from rather than from this file.
        self._console_logger.opt(depth=depth).log(str(level), message)
        return self

    def trace(self, message: str) -> ConsoleLogger:
        """Convenience method to log something at TRACE level.

        Args:
            message (str): the trace message to display.
        """
        return self.log(LoggerLevel.TRACE, message, depth=2)

    def debug(self, message: str) -> ConsoleLogger:
        """Convenience method to log something at DEBUG level.

        Args:
            message (str): the debug string to display.
        """
        return self.log(LoggerLevel.DEBUG, message, depth=2)

    def info(self, message: str) -> ConsoleLogger:
        """Convenience method to log something at INFO level.

        Args:
            message (str): the info string to display.
        """
        return self.log(LoggerLevel.INFO, message, depth=2)

    def success(self, message: str) -> ConsoleLogger:
        """Convenience method to log something at SUCCESS level.

        Args:
            message (str): the success message to display.
        """
        return self.log(LoggerLevel.SUCCESS, message, depth=2)

    def warning(self, message: str) -> ConsoleLogger:
        """Convenience method to log something at WARNING level.

        Args:
            message (str): the warning message to display.
        """
        return self.log(LoggerLevel.WARNING, message, depth=2)

    def error(self, message: str) -> ConsoleLogger:
        """Convenience method to log something at ERROR level.

        Args:
            message (str): the error message to display.
        """
        return self.log(LoggerLevel.ERROR, message, depth=2)

    def critical(self, message: str) -> ConsoleLogger:
        """Convenience method to log something at CRITICAL level.

        Args:
            message (str): the critical message to display."""
        return self.log(LoggerLevel.CRITICAL, message, depth=2)

    @classmethod
    def disable(cls, module_name: Optional[str]) -> None:
        """Disable logging from all sources under the specified module, for
        testing purposes only.

        *CAUTION: Also disables any other logger under the same module.*
        This is an untested experimental feature and may not work as expected -- we
        are simply calling the `.enable()` method on the backend logging library.

        See the [loguru documentation](https://loguru.readthedocs.io/en/stable/api/logger.html#loguru._logger.Logger.disable)

        Args:
            module_name (str | None): the module to disable.
        """
        # Note: making this a call to self._console_logger.disable() does not prevent
        # disable() from also disabling the data and comms loggers.
        # There may be another way of handling this, but for now this is to be
        # considered an experimental feature for testing purposes only.
        loguru.logger.disable(module_name)

    @classmethod
    def enable(cls, module_name: Optional[str]) -> None:
        """Enable logging from all sources under the specified module, for
        testing purposes only.

        *CAUTION: Also enables any other logger under the same module.*
        This is an untested experimental feature and may not work as expected -- we
        are simply calling the `.enable()` method on the backend logging library.

        See the [loguru documentation](https://loguru.readthedocs.io/en/stable/api/logger.html#loguru._logger.Logger.enable)

        Args:
            module_name (str): the module to enable.
        """
        loguru.logger.enable(module_name)

    @staticmethod
    def get() -> ConsoleLogger:
        """Get the instance of the console logger, or instantiate the default one if none exists.

        Returns:
            ConsoleLogger
        """
        return ConsoleLogger()

    # -------------- #
    # Static methods #
    # -------------- #
    @staticmethod
    def initialize_comprehensive_logger(app_name: str, verbose=True) -> ConsoleLogger:
        """Construct a comprehensive ConsoleLogger for a main application.

        This is a helper method which attempts to construct a console logger which displays
        all logging levels. Other than the provided application name, all parameters are set
        to their usual defaults, with a filename of "console.log".
        It is a shorthand command for the most debug construction sequence, where you want a
        ConsoleLogger that displays all outputs of your application.

        This must only be used only once per main process, as the first line in a main script. E.g.:
        ```python
        if __name__ == "__main__":
            logger = ConsoleLogger.initialize_comprehensive_logger("my_app")
            # From here on out, you can call ConsoleLogger() to retrieve the logger
            # in modules which use logging.
            # ...
        ```

        Args:
            app_name (str): the name of the main application that is running. This defines the folder where
                logs will be stored.
            verbose (bool, optional): whether the log format should be verbose or terse. Defaults to true.


        Returns:
            ConsoleLogger

        Raises:
            RuntimeError: if the ConsoleLogger has already been constructed.
        """
        if ConsoleLogger.instantiated():
            raise RuntimeError(
                "Failed to construct ConsoleLogger as it has already been instantiated"
            )
        # Make a copy of the default configuration, but with our provided app name instead.
        logger_config = get_default_console_logger_config(app_name)
        params = ConsoleLoggerParams(
            logger_config=logger_config, min_logging_level=LoggerLevel.TRACE, verbose=verbose
        )
        return ConsoleLogger(params)

    @staticmethod
    def initialize_standard_logger(app_name: str, verbose=True) -> ConsoleLogger:
        """Construct a standard ConsoleLogger for a main application.

        This is a helper method which attempts to construct a console logger which displays
        logs at level INFO or above. Other than the provided application name, all parameters are set
        to their usual defaults, with a filename of "console.log".
        It is a shorthand command for the most common standard construction sequence, where you want a
        ConsoleLogger that displays basic information on your application.

        This must only be used only once per main process, as the first line in a main script. E.g.:
        ```python
        if __name__ == "__main__":
            logger = ConsoleLogger.initialize_standard_logger("my_app")
            # From here on out, you can call ConsoleLogger() to retrieve the logger
            # in modules which use logging.
            # ...
        ```

        Args:
            app_name (str): the name of the main application that is running. This defines the folder where
                logs will be stored.
            verbose (bool, optional): whether the log format should be verbose or terse. Defaults to true.

        Returns:
            ConsoleLogger

        Raises:
            RuntimeError: if the ConsoleLogger has already been constructed.
        """
        if ConsoleLogger.instantiated():
            raise RuntimeError(
                "Failed to construct ConsoleLogger as it has already been instantiated"
            )
        # Make a copy of the default configuration, but with our provided app name instead.
        logger_config = get_default_console_logger_config(app_name)
        params = ConsoleLoggerParams(
            logger_config=logger_config, min_logging_level=LoggerLevel.INFO, verbose=verbose
        )
        return ConsoleLogger(params)

    @staticmethod
    def initialize_standard_logger_from_existing_params(
        logger_config: common.LoggerConfig, verbose: bool = True
    ) -> ConsoleLogger:
        if ConsoleLogger.instantiated():
            raise RuntimeError(
                "Failed to construct ConsoleLogger as it has already been instantiated"
            )
        return ConsoleLogger(
            params=ConsoleLoggerParams(
                logger_config=logger_config, min_logging_level=LoggerLevel.INFO, verbose=verbose
            )
        )
