"""
Parsers for structured log data produced by the `moptipy` experiment API.

The `moptipy` :class:`~moptipy.api.execution.Execution` and experiment-running
facility (:func:`~moptipy.api.experiment.run_experiment`) uses the class
:class:`~moptipy.utils.logger.Logger` from module :mod:`~moptipy.utils.logger`
to produce log files complying with
https://thomasweise.github.io/moptipy/#log-files.

Here we provide a skeleton for parsing such log files in form of the class
:class:`~LogParser`. It works similar to SAX-XML parsing in that the data
is read is from files and methods that consume the data are invoked. By
overwriting these methods, we can do useful things with the data.

For example in module :mod:`~moptipy.evaluation.end_results`, the method
:meth:`~moptipy.evaluation.end_results.EndResult.from_logs` can load
:class:`~moptipy.evaluation.end_results.EndResult` records from the logs
and the method :meth:`~moptipy.evaluation.progress.Progress.from_logs` in
module :mod:`~moptipy.evaluation.progress` reads the whole
:class:`~moptipy.evaluation.progress.Progress` that the algorithms make
over time.
"""

from os import listdir
from os.path import basename, dirname, isdir, isfile, join
from typing import Final

from moptipy.api import logging
from moptipy.utils.console import logger
from moptipy.utils.logger import COMMENT_CHAR, SECTION_END, SECTION_START
from moptipy.utils.nputils import rand_seed_check
from moptipy.utils.path import Path
from moptipy.utils.strings import PART_SEPARATOR, sanitize_name


class LogParser:
    """
    A log parser can parse a log file and separate the sections.

    The log parser is designed to load data from text files generated
    by :class:`~moptipy.utils.logger.FileLogger`. It can also recursively
    parse directories.
    """

    def __init__(self,
                 print_begin_end: bool = True,
                 print_file_start: bool = False,
                 print_file_end: bool = False,
                 print_dir_start: bool = True,
                 print_dir_end: bool = True):
        """
        Initialize the log parser.

        :param print_begin_end: log to stdout when starting and ending
            a recursive directory parsing process
        :param print_file_start: log to stdout when opening a file
        :param print_file_end: log to stdout when closing a file
        :param print_dir_start: log to stdout when entering a directory
        :param print_dir_end: log to stdout when leaving a directory
        """
        self.__print_begin_end: Final[bool] = print_begin_end
        self.__print_file_start: Final[bool] = print_file_start
        self.__print_file_end: Final[bool] = print_file_end
        self.__print_dir_start: Final[bool] = print_dir_start
        self.__print_dir_end: Final[bool] = print_dir_end
        self.__depth: int = 0

    # noinspection PyUnusedLocal
    # noinspection PyMethodMayBeStatic
    def start_dir(self, path: Path) -> bool:
        """
        Enter a directory to parse all files inside.

        This method is called by
        :meth:`~moptipy.evaluation.log_parser.LogParser.parse_dir`.
        If it returns `True`, every sub-directory inside of it will be passed
        to :meth:`~moptipy.evaluation.log_parser.LogParser.start_dir`
        and every file will be passed to
        :meth:`~moptipy.evaluation.log_parser.LogParser.start_file`.
        Only if `True` is returned,
        :meth:`~moptipy.evaluation.log_parser.LogParser.end_dir` will be
        invoked and its return value will be the return value of
        :meth:`~moptipy.evaluation.log_parser.LogParser.parse_dir`. If `False`
        is returned, then
        :meth:`~moptipy.evaluation.log_parser.LogParser.parse_dir` will return
        immediately and return `True`.

        :param path: the path of the directory
        :return: `True` if all the files and sub-directories inside the
            directory should be processed, `False` if this directory should
            be skipped and parsing should continue with the next sibling
            directory
        """
        del path
        return True

    # noinspection PyUnusedLocal
    # noinspection PyMethodMayBeStatic
    def end_dir(self, path: Path) -> bool:
        """
        Enter a directory to parse all files inside.

        This method is called by
        :meth:`~moptipy.evaluation.log_parser.LogParser.parse_dir`.
        If it returns `True`, every sub-directory inside of it will be passed
        to :meth:`~moptipy.evaluation.log_parser.LogParser.start_dir`
        and every file will be passed to
        :meth:`~moptipy.evaluation.log_parser.LogParser.start_file`.

        :param path: the path of the directory
        :return: `True` if all the files and sub-directories inside the
            directory should be processed, `False` if this directory should
            be skipped and parsing should continue with the next sibling
            directory
        """
        del path
        return True

    # noinspection PyUnusedLocal
    # noinspection PyMethodMayBeStatic
    def start_file(self, path: Path) -> bool:
        """
        Decide whether to start parsing a file.

        This method is called by
        :meth:`~moptipy.evaluation.log_parser.LogParser.parse_file`. If it
        returns `True`, then we will open and parse the file. If it returns
        `False`, then the fill will not be parsed and
        :meth:`~moptipy.evaluation.log_parser.LogParser.parse_file` will
        return `True` immediately.

        :param path: the file path
        :return: `True` if the file should be parsed, `False` if it should be
            skipped (and
            :meth:`~moptipy.evaluation.log_parser.LogParser.parse_file` should
            return `True`).
        """
        return path.endswith(logging.FILE_SUFFIX)

    # noinspection PyMethodMayBeStatic
    def start_section(self, title: str) -> bool:
        """
        Start a section.

        If this method returns `True`, then all the lines of text of the
        section `title` will be read and together passed to
        :meth:`~moptipy.evaluation.log_parser.LogParser.lines`.
        If this method returns `False`, then the section will be skipped
        and we fast-forward to the next section, if any, or to the call
        of :meth:`~moptipy.evaluation.log_parser.LogParser.end_file`.

        :param title: the section title
        :return: `True` if the section data should be loaded and passed to
            :meth:`lines`, `False` of the section can be skipped. In that
            case, we will fast-forward to the next
            :meth:`~moptipy.evaluation.log_parser.LogParser.start_section`.
        """
        if not title:
            raise ValueError(f"Title cannot be empty, but is {title!r}.")
        if title.startswith(logging.ERROR_SECTION_PREFIX):
            raise ValueError(f"Encountered error section {title!r}.")
        return False

    # noinspection PyUnusedLocal
    # noinspection PyMethodMayBeStatic
    def lines(self, lines: list[str]) -> bool:
        """
        Consume all the lines from a section.

        This method receives the complete text of a section, where all lines
        are separated and put into one list `lines`. Each line is stripped
        from whitespace and comments, empty lines are omitted.
        If this method returns `True`, we will continue parsing the file and
        move to the next section, if any, or directly to the end of the file
        parsing process (and call
        :meth:`~moptipy.evaluation.log_parser.LogParser.end_file`) if there is
        no more section in the file.

        :param lines: the lines to consume
        :return: `True` if further parsing is necessary and the next section
            should be fed to
            :meth:`~moptipy.evaluation.log_parser.LogParser.start_section`,
            `False` if the parsing
            process can be terminated, in which case we will fast-forward to
            :meth:`~moptipy.evaluation.log_parser.LogParser.end_file`
        """
        del lines
        return True

    # noinspection PyMethodMayBeStatic
    def end_file(self) -> bool:
        """
        End a file.

        This method is invoked when we have reached the end of the current
        file. Its return value, `True` or `False`, will then be returned
        by :meth:`~moptipy.evaluation.log_parser.LogParser.parse_file`, which
        is the entry point for the file parsing process.

        :return: the return value to be returned by
            :meth:`~moptipy.evaluation.log_parser.LogParser.parse_file`
        """
        return True

    def parse_file(self, path: str) -> bool:
        """
        Parse the contents of a file.

        This method first calls the function
        :meth:`~moptipy.evaluation.log_parser.LogParser.start_file` to see
        whether the file should be parsed. If
        :meth:`~moptipy.evaluation.log_parser.LogParser.start_file` returns
        `True`, then the file is parsed. If
        :meth:`~moptipy.evaluation.log_parser.LogParser.start_file` returns
        `False`, then this method returns `False` directly.
        If the file is parsed, then
        :meth:`~moptipy.evaluation.log_parser.LogParser.start_section` will
        be invoked for each section (until the parsing is finished) and
        :meth:`~moptipy.evaluation.log_parser.LogParser.lines` for
        each section content (if requested). At the end,
        :meth:`~moptipy.evaluation.log_parser.LogParser.end_file` is invoked.

        This method can either be called directly or is called by
        :meth:`~moptipy.evaluation.log_parser.LogParser.parse_dir`. In the
        latter case, if
        :meth:`~moptipy.evaluation.log_parser.LogParser.parse_file` returned
        `True`, the next file in the current directory will be parsed. If it
        returns `False`, then no file located in the current directory will be
        parsed, while other directories and/or sub-directories will still be
        processed.

        :param path: the file to parse
        :return: the return value received from invoking
            :meth:`~moptipy.evaluation.log_parser.LogParser.end_file`
        """
        file: Final[Path] = Path.file(path)

        retval: bool
        try:
            retval = self.start_file(file)
        except BaseException as be:
            raise ValueError(f"Error when starting file {file!r}") from be

        if retval:
            if self.__print_file_start:
                logger(f"parsing file {file!r}.")
        else:
            if self.__print_file_start:
                logger(f"skipping file {file!r}.")
            return True

        lines: list[str] = []
        buffer: list[str] = []
        state: int = 0
        wants_section: bool = False
        sec_end: str = ""
        section: str = ""
        sect_start: Final[str] = SECTION_START
        sect_end: Final[str] = SECTION_END
        cmt_chr: Final[str] = COMMENT_CHAR

        index: int = 0
        with file.open_for_read() as handle:
            while True:

                # get the next line
                if index >= len(buffer):
                    try:
                        buffer = handle.readlines(128)
                    except BaseException as be:
                        raise ValueError(
                            f"Error when reading lines from file {file!r} "
                            f"while in section {section!r}."
                            if state == 1 else
                            f"Error when reading lines from file {file!r}.") \
                            from be
                    if (buffer is None) or (len(buffer) <= 0):
                        break
                    index = 0

                orig_cur = buffer[index]
                index += 1

                # strip next line from comments and white space
                cur = orig_cur.strip()
                if len(cur) <= 0:
                    continue

                i = cur.find(cmt_chr)
                if i >= 0:
                    cur = cur[:i].strip()
                    if len(cur) <= 0:
                        continue

                if state in (0, 2):
                    if not cur.startswith(sect_start):
                        raise ValueError("Line should start with "
                                         f"{sect_start!r} but is "
                                         f"{orig_cur!r} in file {file!r}.")
                    section = cur[len(sect_start):]
                    if len(section) <= 0:
                        raise ValueError(
                            "Section title cannot be empty in "
                            f"{file!r}, but encountered {orig_cur!r}.")
                    state = 1
                    sec_end = sect_end + section
                    wants_section = self.start_section(section)
                elif state == 1:
                    if cur == sec_end:
                        state = 2
                        if wants_section:
                            try:
                                do_next = self.lines(lines)
                            except BaseException as be:
                                raise ValueError(
                                    "Error when processing section "
                                    f"{section!r} in file {file!r}.") from be
                            lines.clear()
                            if not do_next:
                                break
                    elif wants_section:
                        lines.append(cur)

        if state == 0:
            raise ValueError(f"Log file {file!r} contains no section.")
        if state == 1:
            raise ValueError(f"Log file {file!r} ended before"
                             f"encountering {sec_end!r}.")

        try:
            retval = self.end_file()
        except BaseException as be:
            raise ValueError("Error when ending section parsing "
                             f"of file {file!r}.") from be
        if self.__print_file_end:
            logger(f"finished parsing file {file!r}.")
        return retval

    def parse_dir(self, path: str) -> bool:
        """
        Recursively parse the given directory.

        :param path: the path to the directory
        :return: `True` either if
            :meth:`~moptipy.evaluation.log_parser.LogParser.start_dir`
            returned `False` or
            :meth:`~moptipy.evaluation.log_parser.LogParser.end_dir` returned
            `True`, `False` otherwise
        """
        folder: Final[Path] = Path.directory(path)

        if self.__depth <= 0:
            if self.__depth == 0:
                if self.__print_begin_end:
                    logger("beginning recursive parsing of "
                           f"directory {folder!r}.")
            else:
                raise ValueError(
                    f"Depth must be >= 0, but is {self.__depth}??")
        self.__depth += 1

        if not self.start_dir(folder):
            if self.__print_dir_start:
                logger(f"skipping directory {folder!r}.")
            return True
        if self.__print_dir_start:
            logger(f"entering directory {folder!r}.")

        do_files = True
        do_dirs = True
        for subpath in listdir(folder):
            sub = Path.path(join(folder, subpath))
            if isfile(sub):
                if do_files and (not self.parse_file(sub)):
                    logger(f"will parse no more files in {folder!r}.")
                    if not do_dirs:
                        break
                    do_files = False
            elif isdir(sub) and do_dirs and (not self.parse_dir(sub)):
                logger("will parse no more sub-directories "
                       f"of {folder!r}.")
                if not do_files:
                    break
                do_dirs = False

        retval = self.end_dir(folder)
        if self.__print_dir_end:
            logger(f"finished parsing directory {folder!r}.")

        self.__depth -= 1
        if self.__depth <= 0:
            if self.__depth == 0:
                if self.__print_begin_end:
                    logger(f"finished recursive parsing of "
                           f"directory {folder!r}.")
            else:
                raise ValueError(
                    f"Depth must be >= 0, but is {self.__depth}??")

        return retval

    def parse(self, path: str) -> bool:
        """
        Parse either a directory or a file.

        If `path` identifies a file,
        :meth:`~moptipy.evaluation.log_parser.LogParser.parse_file` is invoked
        and its result is returned. If `path` identifies a directory, then
        :meth:`~moptipy.evaluation.log_parser.LogParser.parse_dir` is invoked
        and its result is returned.

        :param path: a path identifying either a directory or a file.
        :return: the result of the appropriate parsing routing
        :raises ValueError: if `path` does not identify a directory or file
        """
        npath = Path.path(path)
        if isfile(npath):
            return self.parse_file(npath)
        if isdir(npath):
            return self.parse_dir(npath)
        raise ValueError(
            f"Path {npath} is neither a file nor a directory?")


class ExperimentParser(LogParser):
    """A log parser following our pre-defined experiment structure."""

    def __init__(self):
        """Initialize the experiment parser."""
        super().__init__(print_begin_end=True, print_dir_start=True)

        #: The name of the algorithm to which the current log file belongs.
        self.algorithm: str | None = None

        #: The name of the instance to which the current log file belongs.
        self.instance: str | None = None

        #: The random seed of the current log file.
        self.rand_seed: int | None = None

    def start_file(self, path: Path) -> bool:
        """
        Decide whether to start parsing a file and setup meta-data.

        :param path: the file path
        :return: `True` if the file should be parsed, `False` if it should be
            skipped (and
            :meth:`~moptipy.evaluation.log_parser.LogParser.parse_file` should
            return `True`).
        """
        if not super().start_file(path):
            return False

        inst_dir = dirname(path)
        algo_dir = dirname(inst_dir)
        self.instance = sanitize_name(basename(inst_dir))
        self.algorithm = sanitize_name(basename(algo_dir))

        start = f"{self.algorithm}{PART_SEPARATOR}" \
                f"{self.instance}{PART_SEPARATOR}0x"
        base = basename(path)
        if not base.startswith(start):
            raise ValueError(
                f"File name of {path!r} should start with {start!r}.")
        self.rand_seed = rand_seed_check(int(
            base[len(start):(-len(logging.FILE_SUFFIX))], base=16))

        return True

    def end_file(self) -> bool:
        """Finalize parsing a file."""
        self.rand_seed = None
        self.algorithm = None
        self.instance = None
        return super().end_file()
