#!/usr/bin/env python3 -B
# coding=utf-8

"""
Copyright (C) 2022-2024 Plato Mavropoulos
"""

import os
import re
import shutil
import stat
import sys

from pathlib import Path, PurePath
from typing import Callable, Final

from biosutilities.common.system import system_platform
from biosutilities.common.texts import to_string

MAX_WIN_COMP_LEN: Final[int] = 255


def safe_name(in_name: str) -> str:
    """
    Fix illegal/reserved Windows characters
    Can also be used to nuke dangerous paths
    """

    name_repr: str = repr(in_name).strip("'")

    return re.sub(r'[\\/:"*?<>|]+', '_', name_repr)


def safe_path(base_path: str, user_paths: str | list | tuple) -> str:
    """ Check and attempt to fix illegal/unsafe OS path traversals """

    # Convert base path to absolute path
    base_path = real_path(in_path=base_path)

    # Merge user path(s) to string with OS separators
    user_path: str = to_string(in_object=user_paths, sep_char=os.sep)

    # Create target path from base + requested user path
    target_path: str = norm_path(base_path=base_path, user_path=user_path)

    # Check if target path is OS illegal/unsafe
    if is_safe_path(base_path=base_path, target_path=target_path):
        return target_path

    # Re-create target path from base + leveled/safe illegal "path" (now file)
    nuked_path: str = norm_path(base_path=base_path, user_path=safe_name(in_name=user_path))

    # Check if illegal path leveling worked
    if is_safe_path(base_path=base_path, target_path=nuked_path):
        return nuked_path

    # Still illegal, raise exception to halt execution
    raise OSError(f'Encountered illegal path traversal: {user_path}')


def is_safe_path(base_path: str, target_path: str) -> bool:
    """ Check for illegal/unsafe OS path traversal """

    base_path = real_path(in_path=base_path)

    target_path = real_path(in_path=target_path)

    common_path: str = os.path.commonpath((base_path, target_path))

    return base_path == common_path


def norm_path(base_path: str, user_path: str) -> str:
    """ Create normalized base path + OS separator + user path """

    return os.path.normpath(base_path + os.sep + user_path)


def real_path(in_path: str) -> str:
    """ Get absolute path, resolving any symlinks """

    return os.path.realpath(in_path)


def agnostic_path(in_path: str) -> PurePath:
    """ Get Windows/Posix OS-agnostic path """

    return PurePath(in_path.replace('\\', os.sep))


def path_parent(in_path: str) -> Path:
    """ Get absolute parent of path """

    return Path(in_path).parent.absolute()


def path_name(in_path: str, limit: bool = False) -> str:
    """ Get final path component, with suffix """

    comp_name: str = PurePath(in_path).name

    is_win: bool = system_platform()[1]

    if limit and is_win:
        comp_name = comp_name[:MAX_WIN_COMP_LEN - len(extract_suffix())]

    return comp_name


def path_stem(in_path: str) -> str:
    """ Get final path component, w/o suffix """

    return PurePath(in_path).stem


def path_size(in_path: str) -> int:
    """ Get path size (bytes) """

    return os.stat(in_path).st_size


def path_suffixes(in_path: str) -> list[str]:
    """ Get list of path file extensions """

    return PurePath(in_path).suffixes or ['']


def make_dirs(in_path: str, parents: bool = True, exist_ok: bool = True, delete: bool = False):
    """ Create folder(s), controlling parents, existence and prior deletion """

    if delete:
        delete_dirs(in_path=in_path)

    Path.mkdir(Path(in_path), parents=parents, exist_ok=exist_ok)


def delete_dirs(in_path: str) -> None:
    """ Delete folder(s), if present """

    if is_dir(in_path=in_path):
        shutil.rmtree(in_path, onerror=clear_readonly_callback)  # pylint: disable=deprecated-argument


def delete_file(in_path: str) -> None:
    """ Delete file, if present """

    if is_file(in_path=in_path):
        clear_readonly(in_path=in_path)

        os.remove(in_path)


def rename_file(in_path: str, in_dest: str) -> None:
    """ Rename file with path or name destination, if present """

    if is_file(in_path=in_path):
        clear_readonly(in_path=in_path)

        if is_file(in_path=in_dest, allow_broken_links=True):
            clear_readonly(in_path=in_dest)

            out_path: str = in_dest
        else:
            out_path = os.path.join(path_parent(in_path=in_path), in_dest)

        os.replace(in_path, out_path)


def copy_file(in_path: str, out_path: str, metadata: bool = False) -> None:
    """ Copy file to path with or w/o metadata """

    if metadata:
        shutil.copy2(in_path, out_path)
    else:
        shutil.copy(in_path, out_path)


def clear_readonly(in_path: str) -> None:
    """ Clear read-only file attribute """

    os.chmod(in_path, stat.S_IWRITE)


def clear_readonly_callback(in_func: Callable, in_path: str, _) -> None:
    """ Clear read-only file attribute (on shutil.rmtree error) """

    clear_readonly(in_path=in_path)

    in_func(in_path)


def path_files(in_path: str, follow_links: bool = False, root_only: bool = False) -> list[str]:
    """ Walk path to get all files """

    file_paths: list[str] = []

    for root_path, _, file_names in os.walk(in_path, followlinks=follow_links):
        for file_name in file_names:
            file_path: str = os.path.abspath(os.path.join(root_path, file_name))

            if is_file(in_path=file_path):
                file_paths.append(file_path)

        if root_only:
            break

    return file_paths


def is_dir(in_path: str) -> bool:
    """ Check if path is a directory """

    return Path(in_path).is_dir()


def is_file(in_path: str, allow_broken_links: bool = False) -> bool:
    """ Check if path is a regural file or symlink (valid or broken) """

    in_path_abs: str = os.path.abspath(in_path)

    if os.path.lexists(in_path_abs):
        if not is_dir(in_path=in_path_abs):
            if allow_broken_links:
                return os.path.isfile(in_path_abs) or os.path.islink(in_path_abs)

            return os.path.isfile(in_path_abs)

    return False


def is_access(in_path: str, access_mode: int = os.R_OK, follow_links: bool = False) -> bool:
    """ Check if path is accessible """

    if not follow_links and os.access not in os.supports_follow_symlinks:
        follow_links = True

    return os.access(in_path, access_mode, follow_symlinks=follow_links)


def is_file_read(in_path: str) -> bool:
    """ Check if path is a readable file """

    return isinstance(in_path, str) and is_file(in_path=in_path) and is_access(in_path=in_path)


def is_dir_read(in_path: str) -> bool:
    """ Check if path is a readable directory """

    return isinstance(in_path, str) and is_dir(in_path=in_path) and is_access(in_path=in_path)


def is_empty_dir(in_path: str, follow_links: bool = False) -> bool:
    """ Check if directory is empty (file-wise) """

    for _, _, filenames in os.walk(in_path, followlinks=follow_links):
        if filenames:
            return False

    return True


def extract_suffix() -> str:
    """ Set utility extraction stem """

    return '_extracted'


def extract_folder(in_path: str, suffix: str = extract_suffix()) -> str:
    """ Get utility extraction directory """

    return f'{in_path}{suffix}'


def project_root() -> str:
    """ Get project root directory """

    return real_path(in_path=str(Path(__file__).parent.parent))


def runtime_root() -> str:
    """ Get runtime root directory """

    if getattr(sys, 'frozen', False):
        root: str = str(Path(sys.executable).parent)
    else:
        root = project_root()

    return real_path(in_path=root)
