"""A multi-objective process with different search and solution spaces."""

from math import isfinite
from typing import Callable, Final

import numpy as np
from numpy import copyto
from pycommons.io.path import Path
from pycommons.types import type_error

from moptipy.api._mo_process_no_ss import _MOProcessNoSS
from moptipy.api._process_base import _TIME_IN_NS
from moptipy.api.algorithm import Algorithm
from moptipy.api.encoding import Encoding, check_encoding
from moptipy.api.logging import (
    PREFIX_SECTION_ARCHIVE,
    SCOPE_ENCODING,
    SCOPE_SEARCH_SPACE,
    SECTION_RESULT_X,
    SUFFIX_SECTION_ARCHIVE_X,
    SUFFIX_SECTION_ARCHIVE_Y,
)
from moptipy.api.mo_archive import MOArchivePruner, MORecord
from moptipy.api.mo_problem import MOProblem
from moptipy.api.space import Space, check_space
from moptipy.utils.logger import KeyValueLogSection, Logger


class _MOProcessSS(_MOProcessNoSS):
    """A class implementing a process with search and solution space."""

    def __init__(self,
                 solution_space: Space,
                 objective: MOProblem,
                 algorithm: Algorithm,
                 pruner: MOArchivePruner,
                 archive_max_size: int,
                 archive_prune_limit: int,
                 log_file: Path | None = None,
                 search_space: Space | None = None,
                 encoding: Encoding | None = None,
                 rand_seed: int | None = None,
                 max_fes: int | None = None,
                 max_time_millis: int | None = None,
                 goal_f: int | float | None = None) -> None:
        """
        Perform the internal initialization. Do not call directly.

        :param solution_space: the solution space.
        :param objective: the objective function
        :param algorithm: the optimization algorithm
        :param pruner: the archive pruner
        :param archive_max_size: the maximum archive size after pruning
        :param archive_prune_limit: the archive size above which pruning will
            be performed
        :param search_space: the search space.
        :param encoding: the encoding
        :param log_file: the optional log file
        :param rand_seed: the optional random seed
        :param max_fes: the maximum permitted function evaluations
        :param max_time_millis: the maximum runtime in milliseconds
        :param goal_f: the goal objective value. if it is reached, the
            process is terminated
        """
        super().__init__(solution_space=solution_space,
                         objective=objective,
                         algorithm=algorithm,
                         pruner=pruner,
                         archive_max_size=archive_max_size,
                         archive_prune_limit=archive_prune_limit,
                         log_file=log_file,
                         rand_seed=rand_seed,
                         max_fes=max_fes,
                         max_time_millis=max_time_millis,
                         goal_f=goal_f)

        #: The search space.
        self._search_space: Final[Space] = check_space(search_space)
        #: The encoding.
        self._encoding: Final[Encoding] = check_encoding(encoding)
        #: the internal encoder
        self._g: Final[Callable] = encoding.decode
        #: The holder for the currently de-coded solution.
        self._current_y = solution_space.create()
        #: The current best point in the search space.
        self._current_best_x: Final = search_space.create()
        # wrappers
        self.create = search_space.create  # type: ignore
        self.copy = search_space.copy  # type: ignore
        self.to_str = search_space.to_str  # type: ignore
        self.is_equal = search_space.is_equal  # type: ignore
        self.from_str = search_space.from_str  # type: ignore
        self.n_points = search_space.n_points  # type: ignore
        self.validate = search_space.validate  # type: ignore
        self._create_y = solution_space.create  # the y creator

    def f_evaluate(self, x, fs: np.ndarray) -> float | int:
        if self._terminated:
            if self._knows_that_terminated:
                raise ValueError("The process has been terminated and the "
                                 "algorithm knows it.")
            return self._current_best_f

        current_y: Final = self._current_y
        self._g(x, current_y)
        result: Final[int | float] = self._f_evaluate(current_y, fs)
        self._current_fes = current_fes = self._current_fes + 1
        do_term: bool = current_fes >= self._end_fes

        improved: bool = False
        if result < self._current_best_f:
            improved = True
            self._current_best_f = result
            copyto(self._current_best_fs, fs)
            self.copy(self._current_best_x, x)
            self._current_y = self._current_best_y
            self._current_best_y = current_y
            do_term = do_term or (result <= self._end_f)

        if self.check_in(x, fs, True) or improved:
            self._last_improvement_fe = current_fes
            self._current_time_nanos = ctn = _TIME_IN_NS()
            self._last_improvement_time_nanos = ctn

        if do_term:
            self.terminate()

        return result

    def get_copy_of_best_x(self, x) -> None:
        if self._current_fes > 0:
            return self.copy(x, self._current_best_x)
        raise ValueError("No current best x available.")

    def get_copy_of_best_y(self, y) -> None:
        if self._current_fes > 0:
            return self._copy_y(y, self._current_best_y)
        raise ValueError("No current best y available.")

    def log_parameters_to(self, logger: KeyValueLogSection) -> None:
        super().log_parameters_to(logger)
        with logger.scope(SCOPE_SEARCH_SPACE) as sc:
            self._search_space.log_parameters_to(sc)
        with logger.scope(SCOPE_ENCODING) as sc:
            self._encoding.log_parameters_to(sc)

    def _write_result(self, logger: Logger) -> None:
        with logger.text(SECTION_RESULT_X) as txt:
            txt.write(self._search_space.to_str(self._current_best_x))
        super()._write_result(logger)

    def _validate_x(self) -> None:
        """Validate x, if it exists."""
        self._search_space.validate(self._current_best_x)

    def _log_and_check_archive_entry(self, index: int, rec: MORecord,
                                     logger: Logger) -> int | float:
        """
        Write an archive entry.

        :param index: the index of the entry
        :param rec: the record to verify
        :param logger: the logger
        :returns: the objective value
        """
        self.validate(rec.x)
        self.f_validate(rec.fs)
        tfs: Final[np.ndarray] = self._fs_temp

        current_y: Final = self._current_y
        self._g(rec.x, current_y)
        self._solution_space.validate(current_y)
        f: Final[int | float] = self._f_evaluate(current_y, tfs)

        if not np.array_equal(tfs, rec.fs):
            raise ValueError(
                f"expected {rec.fs} but got {tfs} when re-evaluating {rec}")
        if not isinstance(f, int | float):
            raise type_error(f, "scalarized objective value", (int, float))
        if not isfinite(f):
            raise ValueError(f"scalarized objective value {f} is not finite")

        with logger.text(f"{PREFIX_SECTION_ARCHIVE}{index}"
                         f"{SUFFIX_SECTION_ARCHIVE_X}") as lg:
            lg.write(self.to_str(rec.x))

        with logger.text(f"{PREFIX_SECTION_ARCHIVE}{index}"
                         f"{SUFFIX_SECTION_ARCHIVE_Y}") as lg:
            lg.write(self._solution_space.to_str(current_y))

        return f

    def __str__(self) -> str:
        return "MOProcessWithSearchSpace"
