"""Module folder of viperleed.calc.bookkeeper.history.

Defines classes containing information about a single folder in the
history directory.
"""

__authors__ = (
    'Michele Riva (@michele-riva)',
    )
__copyright__ = 'Copyright (c) 2019-2025 ViPErLEED developers'
__created__ = '2024-10-14'
__license__ = 'GPLv3+'

from enum import Enum
import logging
from pathlib import Path
import re
import shutil

from viperleed.calc.bookkeeper.constants import HISTORY_FOLDER_RE
from viperleed.calc.bookkeeper.history.constants import HISTORY_INFO_NAME
from viperleed.calc.bookkeeper.history.errors import CantRemoveEntryError
from viperleed.calc.bookkeeper.history.errors import MetadataMismatchError
from viperleed.calc.bookkeeper.history.meta import BookkeeperMetaFile
from viperleed.calc.bookkeeper.log import LogFiles
from viperleed.calc.bookkeeper.mode import BookkeeperMode as Mode
from viperleed.calc.bookkeeper.utils import make_property
from viperleed.calc.constants import DEFAULT_HISTORY
from viperleed.calc.lib.dataclass_utils import frozen
from viperleed.calc.lib.dataclass_utils import non_init_field
from viperleed.calc.lib.dataclass_utils import set_frozen_attr
from viperleed.calc.sections.cleanup import MOVED_LABEL


LOGGER = logging.getLogger(__name__)


class FolderFixAction(Enum):
    """Fixing action performed on a history folder in --fix mode."""

    ADD_METADATA = (
        'Note that it is not possible to determine whether folders to which '
        'metadata files were added were created together with others during '
        'the same viperleed.calc execution. This may have an impact on how '
        'these folders are discarded or analyzed.'
        )


@frozen
class IncompleteHistoryFolder:
    """A history folder that may contain incomplete information.

    The primary difference with respect to a "finalized" HistoryFolder
    is that an IncompleteHistoryFolder may still have files and folders
    added to it. As such, it does not contain the metadata file nor
    has means to discern whether it is a "primary" folder (i.e.,
    created from files/folders in the bookkeeper's cwd that were
    generated by calc) or a workhistory-derived one.
    """

    path: Path

    # Attributes computed during __post_init__
    job_num: int = non_init_field()
    tensor_num: int = non_init_field()
    timestamp: str = non_init_field()

    name = make_property('path.name')

    def __post_init__(self):
        """Set up non-initialization attributes from the path given."""
        self._analyze_path()

    @property
    def exists(self):
        """Return whether this directory is present in history."""
        return self.path.is_dir()

    def copy_file_or_directory(self, file_path, with_name=None):
        """Copy `file_path` to this folder (optionally `with_name`)."""
        dest_name = with_name or file_path.name
        copy = shutil.copy2 if file_path.is_file() else shutil.copytree
        try:
            copy(file_path, self.path / dest_name)
        except OSError as exc:
            err_msg = f'Failed to copy {file_path.name}'
            if with_name != file_path.name:
                err_msg += f' as {dest_name}'
            err_msg += f' to {DEFAULT_HISTORY}. {exc}'
            LOGGER.error(err_msg)

    def _analyze_path(self):
        """Collect information from the history folder at self.path."""
        path = self.path
        match_ = HISTORY_FOLDER_RE.fullmatch(path.name)
        if not match_:
            raise ValueError(
                f'Invalid {DEFAULT_HISTORY} folder {path.name} at '
                f'{path.parent}. Does not match {HISTORY_FOLDER_RE.pattern}.'
                )
        set_frozen_attr(self, 'tensor_num', int(match_['tensor_num']))
        set_frozen_attr(self, 'job_num', int(match_['job_num']))

        # Now timestamp. This is always the last bit, after the
        # underscore. Notice that, since we never add PREVIOUS
        # workhistory folders to history, the timestamp can only
        # match "(moved-)\d{6}-\d{6}". The "moved-" bit is also
        # very unlikely, as it is only added by bookkeeper if it
        # does not find a log file.
        match_ = re.fullmatch(
            rf'.*_(?P<timestamp>({MOVED_LABEL})?\d{{6}}-\d{{6}})',
            match_['rest'],
            )
        if not match_:
            raise ValueError(
                f'Invalid {DEFAULT_HISTORY} folder {path.name} at '
                f'{path.parent}. Does not end with a timestamp.'
                )
        set_frozen_attr(self, 'timestamp', match_['timestamp'])


@frozen
class HistoryFolder(IncompleteHistoryFolder):
    """A collection of information concerning a folder in history."""

    # Attributes computed during __post_init__
    logs: LogFiles = non_init_field()
    metadata: BookkeeperMetaFile = non_init_field()

    parent = make_property('metadata.parent')
    hash_ = make_property('metadata.hash_')

    @property
    def has_metadata(self):
        """Return whether this folder contains a metadata file."""
        # pylint: disable-next=no-member    # It's a BookkeeperMetaFile
        return self.metadata.file.is_file()

    def check_consistent_with_entry(self, entry):
        """Raise unless this folder is consistent with a history.info entry."""
        # Folder names should match
        entry_folder_name = entry.folder_name.value.strip()
        if self.name != entry_folder_name:
            raise CantRemoveEntryError(
                f'Folder names differ: directory name is {self.name!r}, '
                f'{HISTORY_INFO_NAME} entry has {entry_folder_name!r} instead.'
                )
        # Tensor numbers too
        entry_tensors = ((0,) if entry.tensor_nums.no_tensors
                         else entry.tensor_nums.value)
        if self.tensor_num not in entry_tensors:
            raise CantRemoveEntryError(
                f'Tensor number from folder name ({self.tensor_num}) is '
                f'not among the ones in the {HISTORY_INFO_NAME} entry '
                f'({entry_tensors}).'
                )
        # And the same for the job ids
        if self.job_num not in entry.job_nums.value:
            raise CantRemoveEntryError(
                f'Job number from folder name ({self.job_num}) is not '
                f'among the ones in the {HISTORY_INFO_NAME} entry '
                f'({entry.job_nums.value}).'
                )

    def check_metadata(self):
        """Raise a MetadataMismatchError if the metadata file is outdated."""
        new_meta = BookkeeperMetaFile(self.path)
        new_meta.compute_hash()
        if new_meta.hash_ != self.hash_:
            raise MetadataMismatchError(
                f'The metadata file in {self.path.name} has a different id'
                f'({self.hash_}) than the one calculated from the contents '
                f'({new_meta.hash_}). This means that either the folder name '
                'or its contents were modified.'
                )

    def fix(self):
        """Fix the contents of this folder. Return a set of fix actions."""
        fix_actions = set()
        if not self.has_metadata:
            LOGGER.info(f'Adding metadata file in folder {self.path.name}.')
            # pylint: disable-next=no-member  # It's BookkeeperMetaFile
            self.metadata.write()
            fix_actions.add(FolderFixAction.ADD_METADATA)
        return fix_actions

    def mark_as_domains_main(self, domain_paths, domain_folders):
        """Register this folder as the main of a DOMAINS calculation.

        Parameters
        ----------
        domain_paths : Sequence
            Paths to the root domain folders that should be registered.
        domain_folders : Sequence
            The history folders of the domains to be registered. Items
            should be HistoryFolder instances or None. Only those that
            are non-None are registered.

        Raises
        ------
        ValueError
            If `domain_paths` and `domain_folders` have different
            lengths.
        """
        if len(domain_paths) != len(domain_folders):
            raise ValueError(
                f'Inconsistent number of paths ({len(domain_paths)}) '
                f'and folders ({len(domain_folders)}).'
                )
        # Keep only folders that are non-None
        domains = ((str(p), f.hash_)
                   for (p, f) in zip(domain_paths, domain_folders)
                   if f)
        # pylint: disable-next=no-member  # Inference
        self.metadata.mark_as_domains_main(domains)

    def mark_as_domain(self, main_path, main_folder):
        """Register this folder as a subdomain of a main one.

        Parameters
        ----------
        main_path : Pathlike
            Absolute path to the root folder of the main calculation.
        main_folder : HistoryFolder
            The history subfolder of the main calculation that should
            be used for marking this one.

        Returns
        -------
        None.
        """
        # pylint: disable-next=no-member  # Inference
        self.metadata.mark_as_domain((str(main_path), main_folder.hash_))

    def _analyze_path(self):
        """Collect information from the history folder at self.path."""
        if not self.path.is_dir():
            raise ValueError(f'{self.path} is not a directory.')
        super()._analyze_path()
        self._collect_logs()
        self._collect_metadata()

    def _collect_logs(self):
        """Collect information about log files in the root folder."""
        set_frozen_attr(self, 'logs', LogFiles(self.path))
        # pylint: disable-next=no-member  # It's a LogFiles
        self.logs.collect()

    def _collect_metadata(self):
        """Collect information from the metadata file."""
        set_frozen_attr(self, 'metadata', BookkeeperMetaFile(self.path))
        try:
            # pylint: disable-next=no-member  # Inference
            self.metadata.read()
        except FileNotFoundError:
            folder_name = f'{self.path.parent.name}/{self.path.name}'
            LOGGER.warning(
                f'No metadata file found in {folder_name}. '
                f'Consider running \'bookkeeper {Mode.FIX.long_flag}\'.'
                )
            # pylint: disable-next=no-member  # It's BookkeeperMetaFile
            self.metadata.compute_hash()   # Don't write to file though
