# Copyright 2022 Ashley R. Thomas
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
r"""Classes/functions related to backup-related selections.
"""
from dataclasses import dataclass, field
from fnmatch import fnmatchcase, fnmatch
from glob import iglob
import logging
import os
from pathlib import Path
import platform
import re

from ..common.exception import (
    BackupSelectionError,
    StorageDefinitionNotSpecifiedError,
    StorageDefinitionNotFoundError,
    BackingFileInformationNotFound,
    InvalidStateError,
)
from ..common.constants import *
from .config import (
    AtbuConfig,
    is_existing_filesystem_storage_path,
)
from .backup_core import (
    StorageDefinition,
    SpecificBackupInformation,
    BackupInformationDatabase,
)
from .backup_core import BackupFileInformation

DEFAULT_PLATFORM_EXCLUDE = {
    "Windows": {
        re.compile(os.path.normcase(r"^[a-zA-Z]:\\System Volume Information" "\\\\")),
        re.compile(os.path.normcase(r"^[a-zA-Z]:\\$Recycle.Bin" "\\\\")),
    }
}

SELECTION_SCOPE_BACKUP = "backup"
SELECTION_SCOPE_FILE = "files"
SELECTION_SCOPE_ALL = "all"
SELECTION_SCOPES = [SELECTION_SCOPE_ALL, SELECTION_SCOPE_BACKUP, SELECTION_SCOPE_FILE]

SELECTION_BACKUP_LAST = "last"


class SelectionPattern:
    def __init__(self, raw_command_line_specifier: str):
        parts = raw_command_line_specifier.split(":", maxsplit=1)
        scope = None
        pattern = parts
        if len(parts) > 1:
            scope = parts[0]
            pattern = parts[1]
        if scope not in SELECTION_SCOPES:
            raise BackupSelectionError(
                f"The scope '{scope}' is invalid, must be one of {SELECTION_SCOPES}."
            )
        self.scope = scope
        self.original_pattern = pattern
        self.normcase_pattern = os.path.normcase(pattern)

    @staticmethod
    def is_valid_selection_pattern(arg_to_test: str):
        parts = arg_to_test.split(":", maxsplit=1)
        return len(parts) == 2 and parts[0] in SELECTION_SCOPES


@dataclass
class StorageSelectionInfo:
    location: str
    location_derivation_source_arg: str
    storage_def_name: str
    storage_def: dict
    storage_atbu_cfg: AtbuConfig
    backup_info_dir: Path
    selection_patterns: list[SelectionPattern]


@dataclass
class SpecificBackupSelection:
    storage_def_name: str
    specific_backup_name: str
    specific_backup_info: SpecificBackupInformation
    storage_def: StorageDefinition
    backup_info_dir: Path
    backup_history: BackupInformationDatabase
    sel_info: StorageSelectionInfo
    selected_fi: dict[str, BackupFileInformation] = field(default_factory=dict)


def parse_storage_def_specifiers_patterns(
    raw_arguments: list[str], resolve_storage_def_secrets: bool = False
) -> list[StorageSelectionInfo]:
    sel_info_dict: dict[str, StorageSelectionInfo] = {}
    result: list[StorageSelectionInfo] = []
    default_cfg = AtbuConfig.access_default_config()
    cur_storage_selections: list[StorageSelectionInfo] = None
    for raw_arg in raw_arguments:
        if SelectionPattern.is_valid_selection_pattern(arg_to_test=raw_arg):
            #
            # Valid selection pattern, apply it to cur_storage_selections.
            #
            if not cur_storage_selections:
                raise StorageDefinitionNotSpecifiedError(
                    f"No storage selection to apply pattern '{raw_arg}' to."
                )
            for (
                cur_storage_selection
            ) in cur_storage_selections:  # pylint: disable=not-an-iterable
                cur_storage_selection.selection_patterns.append(
                    SelectionPattern(raw_command_line_specifier=raw_arg)
                )
            continue

        storage_pattern = raw_arg.lower()
        raw_arg_split = raw_arg.split(":", maxsplit=1)
        if (
            raw_arg_split[0]
            == CONFIG_SECTION_STORAGE_DEFINITION_SPECIFIER_PREFIX[
                : len(raw_arg_split[0])
            ]
        ):
            storage_pattern = raw_arg_split[1]

        storage_specifiers = [
            f"{CONFIG_SECTION_STORAGE_DEFINITION_SPECIFIER_PREFIX}:{n}"
            for n in default_cfg.get_storage_def_names(fnmatch_pattern=storage_pattern)
        ]

        if not storage_specifiers:
            if not is_existing_filesystem_storage_path(storage_location=raw_arg):
                raise StorageDefinitionNotFoundError(
                    f"The specifier '{raw_arg}' did not yield any storage definitions or filesystem backups."
                )
            # The raw_arg is a directory, set it to the specifier to allow following
            # to try to find a configuration.
            storage_specifiers = [raw_arg]

        #
        # Fall through, raw_arg is not a selection pattern, should be storage definition specifier...
        #

        # Per-iteration storage selections.
        cur_storage_selections: list[StorageSelectionInfo] = []

        # For each specifier, find a storage, add to cur_storage_selections
        # for extracting any patterns.
        for storage_specifier in storage_specifiers:
            atbu_cfg_to_use: AtbuConfig
            (
                atbu_cfg_to_use,
                storage_def_name,
                storage_def_dict,
            ) = default_cfg.resolve_storage_location(
                storage_location=storage_specifier,
                resolve_storage_def_secrets=resolve_storage_def_secrets,
                create_if_not_exist=False,
            )
            if not atbu_cfg_to_use or not storage_def_name or not storage_def_dict:
                raise StorageDefinitionNotFoundError(
                    f"WARNING: Storage definition for '{storage_specifier}' not found, skipping."
                )

            storage_sel_info: StorageSelectionInfo = sel_info_dict.get(storage_def_name)
            if not storage_sel_info:
                storage_def = StorageDefinition.storage_def_from_dict(
                    storage_def_name=storage_def_name, storage_def_dict=storage_def_dict
                )

                storage_sel_info = StorageSelectionInfo(
                    location=storage_specifier,
                    location_derivation_source_arg=raw_arg,
                    storage_def_name=storage_def_name,
                    storage_def=storage_def,
                    storage_atbu_cfg=default_cfg,
                    backup_info_dir=atbu_cfg_to_use.get_backup_info_dir(),
                    selection_patterns=[],
                )
                # Add to ordered list of StorageSelectionInfo instance results.
                result.append(storage_sel_info)

            # Add to the StorageSelectionInfo instances related to
            # the upcoming SelectionPattern instances (further above).
            # A single StorageSelectionInfo instance could be added to
            # cur_storage_selections twice if perhaps the same storage def
            # was specified twice in the overall options.
            cur_storage_selections.append(storage_sel_info)

    # Resulting list should only have any given storage defintion once,
    # with any patterns extracted per above.
    return result


def get_selections_from_file_info_dict(
    sel_pat: SelectionPattern, nc_path_to_fi_dict: dict[str, BackupFileInformation]
) -> list[BackupFileInformation]:
    r: list[BackupFileInformation] = []
    # Check each normcase'ed path against the glob-like pattern.
    for normcase_path, fi in nc_path_to_fi_dict.items():
        # path and pattern are already normcase so using fnmatchcase is fine.
        if not fnmatchcase(normcase_path, sel_pat.normcase_pattern):
            continue
        # Sanity check state, ensure file is resolved with backing_fi.
        if fi.is_unchanged_since_last and not fi.backing_fi:
            # If unchanged file has unresolved backing_fi, fail.
            raise BackingFileInformationNotFound(
                f"An unchanged since last file has no backing file information: "
                f"fi={fi.path}"
            )
        r.append(fi)
    return r


def get_specific_backup_selections(
    sel_info_list: list[StorageSelectionInfo],
) -> list[list[SpecificBackupSelection]]:
    result: list[list[SpecificBackupSelection]] = []
    history_dict: dict[str, BackupInformationDatabase] = {}
    # For each storage definition being filtered...
    for si in sel_info_list:
        if not si.selection_patterns:
            # No filters, skip.
            # TODO: Should this default to none or * all ? (TBD)
            # For now, create a dummy SpecificBackupSelection to allow
            # querying of storage defintions via this code path.
            specific_backup_sel = SpecificBackupSelection(
                storage_def_name=si.storage_def_name,
                specific_backup_name=None,
                specific_backup_info=None,
                storage_def=si.storage_def,
                backup_info_dir=si.backup_info_dir,
                backup_history=None,
                sel_info=si,
            )
            result.append([specific_backup_sel])
            continue
        # For the si storage definition, extract all selected specific backup instances.
        specific_backup_selections: list[SpecificBackupSelection] = []
        for sp in si.selection_patterns:
            if sp.scope not in [SELECTION_SCOPE_BACKUP]:
                continue
            #
            # Share the same storage definition history with SpecificBackupSelection
            # instances of the same storage definition as follows.
            #
            history_key = f"{si.storage_def_name}:{si.backup_info_dir}"
            backup_history = history_dict.get(history_key)
            if not backup_history:
                backup_history = BackupInformationDatabase.create_from_file(
                    backup_base_name=si.storage_def_name,
                    backup_info_dir=si.backup_info_dir,
                )
            # For each specific backup, from newest to oldest...
            for sbi in backup_history.get_specific_backups():
                # If user specified SELECTION_BACKUP_LAST, always match and
                # select the first most recent specific backup.
                # If the user specified something actual fnmatch criteria and
                # it matches, select the backup.
                # All other cases skip this specific backup.
                if (
                    not sp.original_pattern.lower() == SELECTION_BACKUP_LAST
                    and not fnmatch(sbi.specific_backup_name, sp.normcase_pattern)
                ):
                    continue
                # Match, backup selections.
                specific_backup_sel = SpecificBackupSelection(
                    storage_def_name=si.storage_def_name,
                    specific_backup_name=sbi.specific_backup_name,
                    specific_backup_info=sbi,
                    storage_def=si.storage_def,
                    backup_info_dir=si.backup_info_dir,
                    backup_history=backup_history,
                    sel_info=si,
                )
                specific_backup_selections.append(specific_backup_sel)
                if sp.original_pattern.lower() == SELECTION_BACKUP_LAST:
                    # With selector SELECTION_BACKUP_LAST skip backups
                    # older than the last one (the most recent one).
                    break
        for sp in si.selection_patterns:
            if sp.scope != SELECTION_SCOPE_FILE:
                continue
            specific_backup_sel: SpecificBackupSelection
            # For each selected specific backup, from newest to oldest...
            for specific_backup_sel in specific_backup_selections:
                # Place all specific backup file info into a dict keyed w/normcase path.
                sb_nc_path_to_fi_dict: dict[str, BackupFileInformation] = {}
                for sb_fi in specific_backup_sel.specific_backup_info.all_file_info:
                    sb_nc_path_to_fi_dict[sb_fi.nc_path_without_root] = sb_fi
                # Determine which specific backup file info matches the file selection pattern.
                list_sel_fi = get_selections_from_file_info_dict(
                    sel_pat=sp, nc_path_to_fi_dict=sb_nc_path_to_fi_dict
                )
                # For each matching specific backup file info, if it has not already
                # matched the same path with a newer file info, add it to the selection.
                # There should not be duplicate paths per specific backup.
                # Callers will use functions like get_all_specific_backup_file_info to
                # reduce all selections across all specific backups to a single of duplicates
                # where applicable. That is not what the following does. The following is a
                # sanity check.
                for sel_fi in list_sel_fi:
                    if not specific_backup_sel.selected_fi.get(
                        sel_fi.nc_path_without_root
                    ):
                        specific_backup_sel.selected_fi[
                            sel_fi.nc_path_without_root
                        ] = sel_fi
        if specific_backup_selections:
            result.append(specific_backup_selections)
    return result


def verify_specific_backup_selection_list(sbs_list: list[SpecificBackupSelection]):
    storage_def_name = None
    backup_info_dir = None
    for sbs in sbs_list:
        if not storage_def_name:
            storage_def_name = sbs.storage_def_name
            backup_info_dir = sbs.backup_info_dir
        elif (
            storage_def_name != sbs.storage_def_name
            or backup_info_dir != sbs.backup_info_dir
        ):
            raise InvalidStateError(
                f"All SpecificBackupSelection instances in the list must target the same storage definition."
            )


def get_all_specific_backup_file_info(
    sbs_list: list[SpecificBackupSelection],
) -> list[BackupFileInformation]:
    sb_nc_path_to_fi_dict: dict[str, BackupFileInformation] = {}
    # The sbs_list contains SpecificBackupSelection instances from
    # the most recent to oldest specific backup examined as part of
    # the selection process. For each SpecificBackupSelection instance
    # in that order...
    for sbs in sbs_list:
        # Go through the SpecificBackupSelection instance's items, if an item
        # is not in the result sb_nc_path_to_fi_dict, add it. This means
        # newest BackupFileInformation wins.
        for _, sbs_fi in sbs.selected_fi.items():
            if not sb_nc_path_to_fi_dict.get(sbs_fi.nc_path_without_root):
                sb_nc_path_to_fi_dict[sbs_fi.nc_path_without_root] = sbs_fi
    # Return a list of BackupFileInformation instances sorted by normcase path.
    # The sorting is not essential but during operations it can be nice to see
    # files processed with in an order making sense. Being nonessential, this
    # can be changed if needed.
    return sorted(
        [v for _, v in sb_nc_path_to_fi_dict.items()],
        key=lambda sb_fi: sb_fi.nc_path_without_root,
    )


def user_specifiers_to_selections(
    specifiers: list[str], no_selections_ok: bool = False
) -> list[list[SpecificBackupSelection]]:

    # Get selection information from all specifiers.
    sel_info_list = parse_storage_def_specifiers_patterns(
        raw_arguments=specifiers, resolve_storage_def_secrets=True
    )

    # Validate selection information.
    if not no_selections_ok:
        for sel_info in sel_info_list:
            if len(sel_info.selection_patterns) == 0:
                raise BackupSelectionError(
                    f"You must have at least one storage, backup, and file specifier. "
                    f"Example: storage:<storage_def_name> backup:last files:*"
                )

    # Get selections using selection information.
    sbs_list_list = get_specific_backup_selections(sel_info_list)

    if not no_selections_ok:
        # Validate selections.
        for sbs_list in sbs_list_list:
            file_info_count = 0
            for sbs in sbs_list:
                file_info_count += len(sbs.selected_fi)
            if file_info_count == 0:
                raise BackupSelectionError(
                    f"No selected files for {sbs_list[0].storage_def_name}. "
                    f"You must have at least one storage, backup, and file specifier. "
                    f"Example: storage:<storage_def_name> backup:last files:*"
                )

    # Everything seems OK, return selections to caller.
    return sbs_list_list


def get_storage_defintions_from_sbs_list_list(
    sbs_list_list: list[list[SpecificBackupSelection]],
) -> list[StorageDefinition]:
    storage_def_dict: dict[str, StorageDefinition] = {}
    for sbs_list in sbs_list_list:
        for sbs in sbs_list:
            storage_def_dict[sbs.storage_def_name] = sbs.storage_def
    storage_def_list: list[StorageDefinition] = sorted(
        storage_def_dict.values(), key=lambda sd: sd.storage_def_name
    )
    return storage_def_list


def is_system_excluded_path(path):
    platform_excludes = DEFAULT_PLATFORM_EXCLUDE.get(platform.system())
    if platform_excludes is not None:
        for pe in platform_excludes:
            if pe.match(os.path.normcase(path)):
                return True
    return False


RE_FIND_GLOB_CHARS = re.compile(r"[?\[\]*]")
PATH_SEP_CHARS = r"\/"
RE_CONTAINS_GLOB_CHARS = re.compile(".*[*?[].*")


def get_largest_path_without_wildcards(path: str):
    m = RE_FIND_GLOB_CHARS.search(path)
    if m:
        index = m.start()
        while path[index] not in PATH_SEP_CHARS:
            index -= 1
        if index >= 0:
            path = path[: index + 1]
    return path


def get_dirs_without_wildcards(locations: list[str]):
    results: list[str] = []
    for location in locations:
        location = get_largest_path_without_wildcards(location)
        results.append(os.path.abspath(location))
    return results


def ensure_glob_pattern_for_dir(dir_wc: str):
    """For a directory, if no glob pattern is present, ensure
    the glob "**" pattern is joined.
    """
    nc_dir_wc = os.path.normcase(dir_wc)
    nc_drive = os.path.splitdrive(nc_dir_wc)[0]
    if not RE_CONTAINS_GLOB_CHARS.match(dir_wc) and (
        os.path.isdir(dir_wc) or nc_dir_wc == nc_drive
    ):
        dir_wc = os.path.join(dir_wc, "**")
    return dir_wc


@dataclass
class SourceSpecifierDiscoveries:
    source_dir: str
    source_dir_wc: str
    discovered_paths: list[BackupFileInformation]


def get_local_file_information(
    src_dirs_wc: list[str], exclude_patterns: list[str]
) -> list[BackupFileInformation]:
    r"""Given locations containing one or more paths to search, return a list
    of discovered files.

    Each path in src_dirs_wc may or may not contain glob patterns. If a path in
    src_dirs_wc is a directory without a glob pattern, the '**' glob pattern
    will be appened. For example, "c:\abc" will become "c:\\abc\\**" to
    cause a recursive search of all files.

    This function will return a list of file information each of which will
    have its file_info.discovery_path set to the src_dirs_wc leading to its
    discovery.
    """

    exclude_patterns = list(map(os.path.normcase, exclude_patterns))

    def is_ignored(path):
        if is_system_excluded_path(path):
            logging.debug(f"Ignoring platform-excluded path: {path}")
            return True
        nonlocal exclude_patterns
        if len(exclude_patterns) == 0:
            return False
        path_nc = os.path.normcase(path)
        if any(map(lambda pat: fnmatchcase(name=path_nc, pat=pat), exclude_patterns)):
            logging.info(f"Ignoring user-excluded path: {path}")
            return True
        return False

    # Both src_dirs_wc and src_dirs_no_wc can be correlated by index.
    # Start by sorting caller's selections by length/normcase. Length
    # means discovery by shortest selector wins.
    src_dirs_wc.sort(key=lambda p: (len(p), os.path.normcase(p)))
    src_dirs_no_wc: list[str] = get_dirs_without_wildcards(locations=src_dirs_wc)

    # Ensure no duplicate paths in final selection.
    all_paths = set()

    # Track which src_dirs_wc selector discovered which file path.
    srcspec_to_disc: dict[str, SourceSpecifierDiscoveries] = {}

    # For each caller selection...
    for idx, src_dir_wc in enumerate(src_dirs_wc):

        # Get corresponding path without any glob patterns ("wildcards" or "wc").
        src_dir_no_wc = src_dirs_no_wc[idx]
        src_dir_no_wc_nc = os.path.normcase(src_dir_no_wc)

        # If location is either a directory or drive letter alone without
        # any glob patterns, then add glob pattern '**'.
        nc_location = os.path.normcase(src_dir_wc)
        nc_drive = os.path.splitdrive(nc_location)[0]
        if not RE_CONTAINS_GLOB_CHARS.match(src_dir_wc) and (
            os.path.isdir(src_dir_wc) or nc_location == nc_drive
        ):
            src_dir_wc = os.path.join(src_dir_wc, "**")

        # Search, removing anything already found.
        discovered = set(iglob(pathname=src_dir_wc, recursive=True)).difference(
            all_paths
        )

        # Refine further, retaining only files which are not ignored.
        discovered = [v for v in discovered if os.path.isfile(v) and not is_ignored(v)]

        if len(discovered) == 0:
            # Nothing left, next item.
            continue

        # For the given search root folder (without glob pattern),
        # find an existing SourceSpecifierDiscoveries instance, if
        # non found, create one.
        src_spec_disc = srcspec_to_disc.get(src_dir_no_wc_nc)
        if not src_spec_disc:
            src_spec_disc = SourceSpecifierDiscoveries(
                source_dir=src_dir_no_wc, source_dir_wc=src_dir_wc, discovered_paths=[]
            )
            srcspec_to_disc[src_dir_no_wc_nc] = src_spec_disc

        # Add discovered files to instance, assocating them with
        # root path used to find the selections themselves.
        src_spec_disc.discovered_paths.extend(discovered)

        # Track files added, do not add more than once.
        all_paths.update(discovered)

    # Create results for caller by processing each SourceSpecifierDiscoveries instance.
    disc_list = sorted(
        srcspec_to_disc.values(), key=lambda d: os.path.normcase(d.source_dir)
    )
    file_info_list: list[BackupFileInformation] = []
    # For each caller-specified search path...
    for idx, disc in enumerate(disc_list):
        # For each file discovered by caller's search path...
        for p in disc.discovered_paths:
            if not os.path.normcase(p).startswith(os.path.normcase(disc.source_dir)):
                raise InvalidStateError(
                    f"Expected file to have path starting with discovery source: "
                    f"source_dir_wc={disc.source_dir_wc} "
                    f"file_info.path={p.path}"
                )
            # Create the result list with file info
            # indicating discovery source dir.
            file_info_list.append(
                BackupFileInformation(path=p, discovery_path=disc.source_dir)
            )
    return file_info_list
