import base64
import copy
import glob
import ipaddress
import logging
import os
import re
import socket
import time
import uuid
from datetime import UTC, datetime, timedelta
from functools import lru_cache, wraps
from http.cookiejar import MozillaCookieJar
from pathlib import Path
from typing import Any

from Crypto.Cipher import AES

LOG: logging.Logger = logging.getLogger("Utils")

ALLOWED_SUBS_EXTENSIONS: set[str] = {".srt", ".vtt", ".ass"}
"Allowed subtitle file extensions."

FILES_TYPE: list = [
    {"rx": re.compile(r"\.(avi|ts|mkv|mp4|mp3|mpv|ogm|m4v|webm|m4b)$", re.IGNORECASE), "type": "video"},
    {"rx": re.compile(r"\.(mp3|flac|aac|opus|wav|m4a)$", re.IGNORECASE), "type": "audio"},
    {"rx": re.compile(r"\.(srt|ass|ssa|smi|sub|idx)$", re.IGNORECASE), "type": "subtitle"},
    {"rx": re.compile(r"\.(jpg|jpeg|png|gif|bmp|webp)$", re.IGNORECASE), "type": "image"},
    {"rx": re.compile(r"\.(txt|nfo|md|json|yml|yaml|plexmatch)$", re.IGNORECASE), "type": "text"},
    {"rx": re.compile(r"\.(nfo|json|jpg|torrent|\.info\.json)$", re.IGNORECASE), "type": "metadata"},
]
"File type patterns for sidecar files."

TAG_REGEX: re.Pattern[str] = re.compile(r"%{([^:}]+)(?::([^}]*))?}c")
"Regex to find tags in templates."


class FileLogFormatter(logging.Formatter):
    def formatTime(self, record, datefmt=None):  # noqa: ARG002, N802
        return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds")


def timed_lru_cache(ttl_seconds: int, max_size: int = 128):
    """
    Decorator that applies an LRU cache with a time-to-live (TTL) to a function.
    Supports both synchronous and asynchronous functions.

    Args:
        ttl_seconds (int): Time-to-live in seconds.
        max_size (int): Maximum size of the cache.

    Returns:
        Decorated function with caching capabilities.

    """
    import inspect

    def decorator(func):
        is_async = inspect.iscoroutinefunction(func)

        if is_async:
            # For async functions, we need to cache the actual result, not the coroutine
            cache = {}
            cache_expiry = {}

            @wraps(func)
            async def async_wrapper(*args, **kwargs):
                key = str(args) + str(sorted(kwargs.items()))
                current_time = time.monotonic()

                # Check if we have a cached result that hasn't expired
                if key in cache and key in cache_expiry and current_time < cache_expiry[key]:
                    cached_result = cache[key]
                    try:
                        return copy.deepcopy(cached_result)
                    except Exception:
                        return cached_result

                # Call the function and cache the result
                result = await func(*args, **kwargs)
                try:
                    cached_value = copy.deepcopy(result)
                except Exception:
                    cached_value = result

                cache[key] = cached_value
                cache_expiry[key] = current_time + ttl_seconds

                # Limit cache size
                if len(cache) > max_size:
                    # Remove oldest entries
                    oldest_keys = sorted(cache_expiry.keys(), key=lambda k: cache_expiry[k])[: len(cache) - max_size]
                    for old_key in oldest_keys:
                        cache.pop(old_key, None)
                        cache_expiry.pop(old_key, None)

                try:
                    return copy.deepcopy(cached_value)
                except Exception:
                    return cached_value

            # Expose cache management methods
            def cache_clear():
                cache.clear()
                cache_expiry.clear()

            def cache_info():
                # Use functools._CacheInfo for compatibility with standard lru_cache
                from functools import _CacheInfo

                return _CacheInfo(hits=0, misses=len(cache), maxsize=max_size, currsize=len(cache))

            async_wrapper.cache_clear = cache_clear
            async_wrapper.cache_info = cache_info
            return async_wrapper

        # For sync functions, use the original implementation

        @lru_cache(maxsize=max_size)
        def cached(*args, **kwargs):
            result = func(*args, **kwargs)
            try:
                return copy.deepcopy(result)
            except Exception:
                return result

        cached_expiry = time.monotonic() + ttl_seconds

        @wraps(func)
        def sync_wrapper(*args, **kwargs):
            nonlocal cached_expiry
            if time.monotonic() >= cached_expiry:
                cached.cache_clear()
                cached_expiry = time.monotonic() + ttl_seconds
            result = cached(*args, **kwargs)
            try:
                return copy.deepcopy(result)
            except Exception:
                return result

        # expose cache_clear, cache_info
        sync_wrapper.cache_clear = cached.cache_clear
        sync_wrapper.cache_info = cached.cache_info
        return sync_wrapper

    return decorator


def calc_download_path(base_path: str | Path, folder: str | None = None, create_path: bool = True) -> str:
    """
    Calculates download path.

    Args:
        base_path (str): Base download path.
        folder (str): Folder to add to the base path.
        create_path (bool): Create the path if it does not exist.

    Returns:
        Download path with base folder factored in.

    """
    if not isinstance(base_path, Path):
        base_path = Path(base_path)

    if not folder:
        return str(base_path)

    folder = folder.removeprefix("/")

    realBasePath: Path = base_path.resolve()
    download_path: Path = Path(realBasePath).joinpath(folder).resolve(strict=False)

    if not str(download_path).startswith(str(realBasePath)):
        msg: str = f'Folder "{folder}" must resolve inside the base download folder "{realBasePath}".'
        raise Exception(msg)

    try:
        download_path.relative_to(realBasePath)
    except ValueError as e:
        msg = f'Folder "{folder}" must resolve inside the base download folder "{realBasePath}".'
        raise Exception(msg) from e

    if not download_path.is_dir() and create_path:
        download_path.mkdir(parents=True, exist_ok=True)

    return str(download_path)


def _is_safe_key(key: Any) -> bool:
    """
    Check if a dictionary key is safe for merging.

    Blocks:
    - All dunder attributes (__*__)
    - Non-string keys (for consistency)
    - Keys that could be dangerous variations
    """
    # Only allow string keys
    if not isinstance(key, str):
        return False

    # Block empty keys and whitespace-only keys
    if not key or not key.strip():
        return False

    # Block only truly dangerous dunder patterns
    key_stripped: str = key.strip()

    # Block dunder attributes (starts AND ends with __)
    return not (key_stripped.startswith("__") and key_stripped.endswith("__"))


def merge_dict(
    source: dict, destination: dict, max_depth: int = 50, max_list_size: int = 10000, _depth: int = 0, _seen: set = None
) -> dict:
    """
    Merge data from source into destination.

    Args:
        source (dict): Source data
        destination (dict): Destination data
        max_depth (int): Maximum recursion depth allowed (default: 50)
        max_list_size (int): Maximum list size allowed (default: 10000)
        _depth (int): Internal recursion depth counter
        _seen (set): Internal circular reference tracker

    Returns:
        dict: The merged dictionary

    Raises:
        TypeError: If source or destination are not dictionaries
        RecursionError: If recursion depth exceeds safety limit
        ValueError: If circular reference is detected

    """
    if not isinstance(source, dict) or not isinstance(destination, dict):
        msg = "Both source and destination must be dictionaries."
        raise TypeError(msg)

    # Prevent deep recursion DoS
    if _depth > max_depth:
        msg: str = f"Recursion depth limit exceeded ({max_depth})"
        raise RecursionError(msg)

    # Initialize circular reference tracking
    if _seen is None:
        _seen = set()

    # Check for circular references
    source_id = id(source)
    dest_id = id(destination)
    if source_id in _seen or dest_id in _seen:
        msg = "Circular reference detected"
        raise ValueError(msg)

    # Track current objects
    current_seen: set[Any | int] = _seen | {source_id, dest_id}

    # Create a clean copy of destination with only safe keys
    destination_copy: dict = {}
    for k, v in destination.items():
        if _is_safe_key(k):
            # Prevent memory DoS from large lists
            if isinstance(v, list) and len(v) > max_list_size:
                destination_copy[k] = v[:max_list_size]  # Truncate large lists
            else:
                destination_copy[k] = copy.deepcopy(v)

    for key, value in source.items():
        # Skip unsafe keys
        if not _is_safe_key(key):
            continue

        destination_value: Any | None = destination_copy.get(key)

        # Recursively merge dictionaries with safety checks
        if isinstance(value, dict) and isinstance(destination_value, dict):
            destination_copy[key] = merge_dict(
                value, destination_value, max_depth, max_list_size, _depth + 1, current_seen
            )

        # Safely extend lists with size limits
        elif isinstance(value, list) and isinstance(destination_value, list):
            # Prevent memory DoS
            combined_size = len(value) + len(destination_value)
            if combined_size > max_list_size:
                # Truncate to stay within limits
                available_space: int = max_list_size - len(destination_value)
                truncated_value: list = value[: max(0, available_space)]
                destination_copy[key] = copy.deepcopy(destination_value) + copy.deepcopy(truncated_value)
            else:
                destination_copy[key] = copy.deepcopy(destination_value) + copy.deepcopy(value)
        # For non-dict values or when destination doesn't have the key
        elif isinstance(value, dict):
            destination_copy[key] = merge_dict(value, {}, max_depth, max_list_size, _depth + 1, current_seen)
        elif isinstance(value, list) and len(value) > max_list_size:
            # Truncate large lists
            destination_copy[key] = copy.deepcopy(value[:max_list_size])
        else:
            destination_copy[key] = copy.deepcopy(value)

    return destination_copy


def check_id(file: Path) -> bool | str:
    """
    Check if we are able to get an id from the file name.
    if so check if any video file with the same id exists.

    Args:
        file (Path): File to check.

    Returns:
        bool|str: False if no file found, else the file path.

    """
    match: re.Match[str] | None = re.search(
        r"(?<=\[)(?:youtube-)?(?P<id>[a-zA-Z0-9\-_]{11})(?=\])", file.stem, re.IGNORECASE
    )
    if not match:
        return False

    id: str | None = match.groupdict().get("id")

    try:
        if not file.parent.exists():
            return False

        for f in file.parent.iterdir():
            if id not in f.stem:
                continue

            if f.suffix != file.suffix:
                continue

            return f.absolute()
    except OSError as e:
        LOG.error(f"Error checking file '{file}': {e!s}")
        return False

    return False


@timed_lru_cache(ttl_seconds=300, max_size=256)
def is_private_address(hostname: str) -> bool:
    ip: str = socket.gethostbyname(hostname)
    ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address = ipaddress.ip_address(ip)
    return ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved or ip_obj.is_link_local


def validate_url(url: str, allow_internal: bool = False) -> bool:
    """
    Validate if the url is valid and allowed.

    Args:
        url (str): URL to validate.
        allow_internal (bool): If True, allow internal URLs or private networks.

    Returns:
        bool: True if the URL is valid and allowed.

    Raises:
        ValueError: If the URL is invalid or not allowed.

    """
    if not url:
        msg = "URL is required."
        raise ValueError(msg)

    try:
        from yarl import URL

        parsed_url = URL(url)
    except ValueError:
        msg = "Invalid URL."
        raise ValueError(msg)  # noqa: B904

    # Check allowed schemes
    if parsed_url.scheme not in ["http", "https"]:
        msg = "Invalid scheme usage. Only HTTP or HTTPS allowed."
        raise ValueError(msg)

    hostname: str | None = parsed_url.host
    if not hostname:
        msg = "Invalid hostname."
        raise ValueError(msg)

    if allow_internal is False:
        try:
            if is_private_address(hostname):
                msg = "Access to internal urls or private networks is not allowed."
                raise ValueError(msg)
        except socket.gaierror as e:
            LOG.error(f"Error resolving hostname '{hostname}': {e!s}")
            msg = "Invalid hostname."
            raise ValueError(msg) from e

    return True


def validate_uuid(uuid_str: str, version: int = 4) -> bool:
    """
    Validate if the UUID is valid.

    Args:
        uuid_str (str): UUID to validate.
        version  (int): The UUID version

    Returns:
        bool: True if the UUID is valid.

    """
    try:
        uuid.UUID(uuid_str, version=version)
        return True
    except ValueError:
        return False


@timed_lru_cache(ttl_seconds=60, max_size=256)
def get_file_sidecar(file: Path | None = None) -> dict[dict]:
    """
    Get sidecar files for the given file.

    Args:
        file (Path|None): The video file.

    Returns:
        dict: A dictionary with sidecar files categorized by type.

    """
    files: dict = {}

    if not file:
        return files

    for i, f in enumerate(file.parent.glob(f"{glob.escape(file.stem)}.*")):
        if f == file or f.is_file() is False or f.stem.startswith("."):
            continue

        if f.stat().st_size < 1:
            continue

        content_type = "Unknown"
        for pattern in FILES_TYPE:
            if pattern["rx"].search(f.name):
                content_type: str = pattern["type"]
                break

        if content_type == "subtitle":
            if f.suffix not in ALLOWED_SUBS_EXTENSIONS:
                continue
            lg: re.Match[str] | None = re.search(r"\.(?P<lang>\w{2,3})\.\w{3}$", f.name)
            lang: str | None = lg.groupdict().get("lang") if lg else "und"
            content: dict[str, Any] = {"file": f, "lang": lang, "name": f"{f.suffix[1:].upper()} ({i}) - {lang}"}
        else:
            content = {"file": f}

        if content_type not in files:
            files[content_type] = []

        files[content_type].append(content)

    images: list[dict] = get_possible_images(str(file.parent))
    if len(images) > 0:
        if "image" not in files:
            files["image"] = []

        files["image"].extend(images)

    return files


def rename_file(old_path: Path, new_name: str) -> tuple[Path, list[tuple[Path, Path]]]:
    """
    Rename a file along with all its sidecar files.

    Args:
        old_path (Path): The original file path.
        new_name (str): The new name for the file (not full path, just the name).

    Returns:
        tuple[Path, list[tuple[Path, Path]]]: Tuple of (new_path, list of (old_sidecar_path, new_sidecar_path) tuples).

    Raises:
        OSError: If renaming fails.
        ValueError: If new_name would cause a collision with existing files.

    """
    new_path: Path = old_path.parent / new_name

    if new_path.exists():
        msg: str = f"Destination '{new_name}' already exists"
        raise ValueError(msg)

    sidecar_data: dict[str, dict] = get_file_sidecar(old_path)
    sidecar_files: list[Path] = []

    for category in sidecar_data.values():
        sidecar_files.extend([item["file"] for item in category if "file" in item and isinstance(item["file"], Path)])

    renamed_sidecars: list[tuple[Path, Path]] = []
    new_stem: str = new_path.stem
    rename_operations: list[tuple[Path, Path]] = []

    for sidecar in sidecar_files:
        sidecar_suffix: str = sidecar.name[len(old_path.stem) :]  # Everything after the original stem
        new_sidecar_path: Path = old_path.parent / f"{new_stem}{sidecar_suffix}"
        if new_sidecar_path.exists():
            msg = f"Sidecar destination '{new_sidecar_path.name}' already exists"
            raise ValueError(msg)

        rename_operations.append((sidecar, new_sidecar_path))

    try:
        renamed_main: Path = old_path.rename(new_path)
        for old_sidecar, new_sidecar in rename_operations:
            try:
                renamed_sidecar: Path = old_sidecar.rename(new_sidecar)
                renamed_sidecars.append((old_sidecar, renamed_sidecar))
            except OSError as e:
                LOG.error(f"Failed to rename sidecar '{old_sidecar}': {e}")
                try:
                    renamed_main.rename(old_path)
                except OSError:
                    LOG.error(f"Failed to rollback main file rename from '{renamed_main}' to '{old_path}'")
                raise

        return renamed_main, renamed_sidecars
    except OSError as e:
        LOG.error(f"Failed to rename '{old_path}' to '{new_path}': {e}")
        raise


def move_file(old_path: Path, target_dir: Path) -> tuple[Path, list[tuple[Path, Path]]]:
    """
    Move a file along with all its sidecar files to a target directory.

    Args:
        old_path (Path): The original file path.
        target_dir (Path): The target directory to move the file to.

    Returns:
        tuple[Path, list[tuple[Path, Path]]]: Tuple of (new_path, list of (old_sidecar_path, new_sidecar_path) tuples).

    Raises:
        OSError: If moving fails.
        ValueError: If target directory doesn't exist or if destination would cause collision.

    """
    if not target_dir.exists():
        msg: str = f"Target directory '{target_dir}' does not exist"
        raise ValueError(msg)

    if not target_dir.is_dir():
        msg = f"Target '{target_dir}' is not a directory"
        raise ValueError(msg)

    new_path: Path = target_dir / old_path.name

    if new_path.exists():
        msg = f"Destination '{new_path}' already exists"
        raise ValueError(msg)

    sidecar_data: dict[str, dict] = get_file_sidecar(old_path)
    sidecar_files: list[Path] = []

    for category in sidecar_data.values():
        sidecar_files.extend([item["file"] for item in category if "file" in item and isinstance(item["file"], Path)])

    renamed_sidecars: list[tuple[Path, Path]] = []
    move_operations: list[tuple[Path, Path]] = []

    for sidecar in sidecar_files:
        new_sidecar_path: Path = target_dir / sidecar.name
        if new_sidecar_path.exists():
            msg = f"Sidecar destination '{new_sidecar_path}' already exists"
            raise ValueError(msg)

        move_operations.append((sidecar, new_sidecar_path))

    try:
        moved_main: Path = old_path.rename(new_path)
        for old_sidecar, new_sidecar in move_operations:
            try:
                moved_sidecar: Path = old_sidecar.rename(new_sidecar)
                renamed_sidecars.append((old_sidecar, moved_sidecar))
            except OSError as e:
                LOG.error(f"Failed to move sidecar '{old_sidecar}': {e}")
                try:
                    moved_main.rename(old_path)
                    # Rollback any sidecars that were already moved
                    for rolled_back_old, rolled_back_new in renamed_sidecars:
                        try:
                            rolled_back_new.rename(rolled_back_old)
                        except OSError:
                            LOG.error(
                                f"Failed to rollback sidecar move from '{rolled_back_new}' to '{rolled_back_old}'"
                            )
                except OSError:
                    LOG.error(f"Failed to rollback main file move from '{moved_main}' to '{old_path}'")
                raise

        return moved_main, renamed_sidecars
    except OSError as e:
        LOG.error(f"Failed to move '{old_path}' to '{new_path}': {e}")
        raise


def get_possible_images(dir: str) -> list[dict]:
    images: list = []

    path_loc = Path(dir, "test.jpg")

    for filename in ["poster", "thumbnail", "artwork", "cover", "fanart"]:
        for ext in [".jpg", ".jpeg", ".png", ".webp"]:
            f = path_loc.with_stem(filename).with_suffix(ext)
            if f.exists():
                images.append({"file": f})

    return images


def get_mime_type(metadata: dict, file_path: Path) -> str:
    """
    Determine the correct MIME type for a video file based on ffprobe metadata.

    Args:
        metadata (dict): Parsed JSON output from ffprobe.
        file_path (str): The path to the video file for fallback detection.

    Returns:
        str: MIME type compatible with HTML5 <video> tag.

    """
    format_name = metadata.get("format_name", "")

    format_to_mime: dict[str, str] = {
        "matroska": "video/x-matroska",  # Default for MKV
        "webm": "video/webm",  # MKV can also be WebM
        "mp4": "video/mp4",
        "mpegts": "video/mp2t",
    }

    if format_name:
        selected = None
        for fmt in format_name.split(","):
            fmt = fmt.strip().lower()
            if fmt in format_to_mime:
                selected: str = format_to_mime[fmt]

        if selected:
            return selected

    import mimetypes

    mime_type, _ = mimetypes.guess_type(str(file_path))
    if mime_type:
        return mime_type

    # Final fallback: Return generic binary type
    return "application/octet-stream"


def get_file(download_path: str | Path, file: str | Path) -> tuple[Path, int]:
    """
    Get the real file path.

    Args:
        download_path (str|Path): Base download path.
        file (str|Path): File path.

    Returns:
        Path: Real file path.
        int: http status code.

    """
    if not isinstance(download_path, Path):
        download_path = Path(download_path)

    try:
        realFile: str = Path(calc_download_path(base_path=download_path, folder=str(file), create_path=False))
        if realFile.exists():
            return (realFile, 200)
    except Exception as e:
        LOG.error(f"Error calculating download path. {e!s}")
        return (Path(file), 404)

    possibleFile: bool | str = check_id(file=realFile)
    if not possibleFile:
        return (realFile, 404)

    return (Path(possibleFile), 302)


def encrypt_data(data: str, key: bytes) -> str:
    """
    Encrypts data using AES-GCM

    Args:
        data (str): The data to encrypt
        key (bytes): The encryption key

    Returns:
        str: The encrypted data as a base64 encoded string

    """
    iv = os.urandom(12)  # AES-GCM requires a 12-byte IV
    cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
    ciphertext, tag = cipher.encrypt_and_digest(data.encode())

    return base64.urlsafe_b64encode(iv + ciphertext + tag).decode()


def decrypt_data(data: str, key: bytes) -> str:
    """
    Decrypts AES-GCM encrypted data

    Args:
        data (str): The encrypted data as a base64 encoded string
        key (bytes): The encryption key

    Returns:
        str: The decrypted data

    """
    try:
        data = base64.urlsafe_b64decode(data)
        iv, ciphertext, tag = data[:12], data[12:-16], data[-16:]
        cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
        plaintext = cipher.decrypt_and_verify(ciphertext, tag)
        return plaintext.decode()
    except Exception:
        return None


def get(
    data: dict | list,
    path: str | list | None = None,
    default: any = None,
    separator=".",
):
    """
    Access data in a nested dictionary or list using a path string or list of keys.

    Args:
        data (dict | list): The data to traverse.
        path (str | list, optional): The path to the desired data. Defaults to None.
        default (any, optional): The default value to return if the path is not found. Defaults to None.
        separator (str, optional): The separator used to split the path string. Defaults to ".".

    Returns:
        any: The value at the specified path, or the default value if not found.

    """
    # If path is empty, return the entire data.
    if not path:
        return data

    # If data is not a dict or list, attempt to convert it.
    if not isinstance(data, dict | list):
        try:
            data = vars(data)
        except Exception:
            pass

    # If path is a list, try each key in order.
    if isinstance(path, list):
        for key in path:
            val = get(data, key, "__not_set", separator)
            if val != "__not_set":
                return val

        return default() if callable(default) else default

    # For non-list path, attempt a direct lookup.
    if isinstance(data, dict):
        if path in data and data[path] is not None:
            return data[path]
    elif isinstance(data, list):
        # If path is an integer index.
        if isinstance(path, int):
            if 0 <= path < len(data) and data[path] is not None:
                return data[path]
        # If path is a numeric string, convert it.
        elif isinstance(path, str) and path.isdigit():
            idx = int(path)
            if 0 <= idx < len(data) and data[idx] is not None:
                return data[idx]

    # If path doesn't contain the separator, return the default.
    if not (isinstance(path, str) and separator in path):
        return default() if callable(default) else default

    # Split the path by the separator and traverse the data structure.
    segments: list[str] = path.split(separator)
    for segment in segments:
        if isinstance(data, dict):
            if segment in data:
                data = data[segment]
            else:
                return default() if callable(default) else default
        elif isinstance(data, list):
            try:
                idx = int(segment)
            except ValueError:
                return default() if callable(default) else default
            if 0 <= idx < len(data):
                data = data[idx]
            else:
                return default() if callable(default) else default
        else:
            return default() if callable(default) else default
    return data


def get_files(
    base_path: Path | str,
    dir: str | None = None,
    page: int = 1,
    per_page: int = 0,
    sort_by: str = "name",
    sort_order: str = "asc",
    search: str | None = None,
) -> tuple[list, int]:
    """
    Get directory contents with optional pagination, sorting, and search.

    Args:
        base_path (Path|str): Base download path.
        dir (str): Directory to check.
        page (int): Page number (1-indexed). Ignored if per_page is 0.
        per_page (int): Items per page. If 0, returns all items.
        sort_by (str): Sort field: name, size, date, type.
        sort_order (str): Sort direction: asc, desc.
        search (str|None): Filter by filename (case-insensitive).

    Returns:
        tuple[list, int]: List of file/directory dicts and total count (before pagination).

    Raises:
        OSError: If the directory is invalid or not a directory.

    """
    if not isinstance(base_path, Path):
        base_path = Path(base_path)

    base_path = base_path.resolve()

    dir_path = base_path
    if dir and dir != "/":
        dir_path: Path = base_path.joinpath(dir)

    try:
        dir_path = dir_path.resolve()
    except OSError as e:
        LOG.warning(f"Failed to resolve '{dir}' - {e}")
        return []

    try:
        dir_path.relative_to(base_path)
    except ValueError:
        LOG.warning(f"Invalid path: '{dir_path}' - must be inside '{base_path}'.")
        return []

    if not str(dir_path).startswith(str(base_path)):
        LOG.warning(f"Invalid path: '{dir_path}' - must be inside '{base_path}'.")
        return []

    if not dir_path.is_dir():
        LOG.warning(f"Invalid path: '{dir_path}' - must be a directory.")
        return []

    contents: list = []
    for file in dir_path.iterdir():
        if file.name.startswith(".") or file.name.startswith("_"):
            continue

        is_symlink: bool = file.is_symlink()
        if is_symlink:
            try:
                test: Path = file.resolve()
                test.relative_to(base_path)
                if not str(test).startswith(str(base_path)):
                    LOG.warning(f"Invalid symlink: '{file}' - must resolve inside '{base_path}'.")
                    continue
            except ValueError:
                LOG.warning(f"Invalid symlink: '{file}' - must resolve inside '{base_path}'.")
                continue
            except OSError:
                LOG.warning(f"Skipping broken symlink: {file}")
                continue

        content_type = None

        for pattern in FILES_TYPE:
            if pattern["rx"].search(file.name):
                content_type = pattern["type"]
                break

        if not content_type and file.is_dir():
            content_type = "dir"

        if not content_type:
            content_type = "download"

        stat: os.stat_result = file.stat()

        contents.append(
            {
                "type": "file" if file.is_file() else "link" if is_symlink else "dir",
                "content_type": content_type,
                "name": file.name,
                "path": str(file.relative_to(base_path)).strip("/"),
                "size": stat.st_size,
                "mime": get_mime_type({}, file) if file.is_file() else "directory",
                "mtime": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
                "ctime": datetime.fromtimestamp(stat.st_ctime, tz=UTC).isoformat(),
                "is_dir": file.is_dir(),
                "is_file": file.is_file(),
                "is_symlink": is_symlink,
            }
        )

    total: int = len(contents)

    if search:
        search_lower: str = search.lower()
        contents = [c for c in contents if search_lower in c["name"].lower()]

    if sort_by == "name":
        contents.sort(key=lambda x: x["name"].lower(), reverse=(sort_order.lower() == "desc"))
    elif sort_by == "size":
        contents.sort(key=lambda x: x["size"], reverse=(sort_order.lower() == "desc"))
    elif sort_by == "date":
        contents.sort(key=lambda x: x["mtime"], reverse=(sort_order.lower() == "desc"))
    elif sort_by == "type":
        contents.sort(key=lambda x: x["content_type"], reverse=(sort_order.lower() == "desc"))

    if per_page > 0:
        offset: int = (page - 1) * per_page
        contents = contents[offset : offset + per_page]

    return contents, total


def clean_item(item: dict, keys: list | tuple) -> tuple[dict, bool]:
    """
    Remove given keys from a dictionary.

    Args:
        item (dict): The item to clean.
        keys (list|tuple): The keys to remove.

    Returns:
        tuple[dict, bool]: The cleaned item and a the status of cleaning operation.

    Raises:
        TypeError: If item is not a dictionary or keys is not a list or tuple.

    """
    status = False
    item = copy.deepcopy(item)

    if not isinstance(item, dict):
        msg = "Item must be a dictionary."
        raise TypeError(msg)

    if not isinstance(keys, list | tuple):
        msg = "Keys must be a list or tuple."
        raise TypeError(msg)

    for key in keys:
        if key not in item:
            continue

        status = True
        item.pop(key)

    return item, status


def strip_newline(string: str) -> str:
    """
    Remove newlines from a string.

    Args:
        string (str): The string to process.

    Returns:
        str: The string without newlines.

    """
    if not string:
        return ""

    res: str = re.sub(r"(\r\n|\r|\n)", " ", string)

    return res.strip() if res else ""


def load_cookies(file: str | Path) -> tuple[bool, MozillaCookieJar]:
    """
    Validate and load a cookie file.

    Args:
        file (str): The cookie file path.

    Returns:
        bool: True if the cookie file is valid.

    """
    try:
        from http.cookiejar import MozillaCookieJar

        cookies = MozillaCookieJar(str(file), None, None)
        cookies.load()

        return (True, cookies)
    except Exception as e:
        msg = f"Invalid cookie file '{file}'. '{e!s}'"
        raise ValueError(msg) from e


def dt_delta(delta: timedelta) -> str:
    """
    Convert a timedelta object to a human-readable string.

    Args:
        delta (timedelta): The timedelta object.

    Returns:
        str: A human-readable string representing the timedelta.

    """
    total_secs = int(delta.total_seconds())
    days, rem = divmod(total_secs, 86400)
    hours, rem = divmod(rem, 3600)
    minutes, secs = divmod(rem, 60)

    parts: list[str] = []
    if days:
        parts.append(f"{days}d")
    if hours:
        parts.append(f"{hours}h")
    if minutes:
        parts.append(f"{minutes}m")
    if secs:
        parts.append(f"{secs}s")

    # if it's under one second
    if not parts:
        parts.append("<1s")

    return " ".join(parts)


def delete_dir(dir: Path) -> bool:
    """
    Delete a directory and all its contents.

    Args:
        dir (Path): The directory to delete.

    Returns:
        bool: True if the directory was deleted successfully, False otherwise.

    """
    if not dir or not dir.exists() or not dir.is_dir():
        return False

    try:
        for item in dir.iterdir():
            if item.is_dir():
                delete_dir(item)
            else:
                item.unlink()
        dir.rmdir()
        return True
    except Exception as e:
        LOG.error(f"Failed to delete directory '{dir}': {e}")
        return False


def init_class[T](cls: type[T], data: dict, _fields=None) -> T:
    """
    Initialize a class instance with data from a dictionary, filtering out keys not present in the class fields.

    Args:
        cls (type): The class to initialize.
        data (dict): The data to use for initialization.
        _fields (set, optional): A set of field names to consider. If None, all fields from the class will be used.

    Returns:
        T: An instance of the class initialized with the provided data.

    """
    from dataclasses import fields

    _classMembers = _fields if _fields is not None else {f.name for f in fields(cls)}
    return cls(**{k: v for k, v in data.items() if k in _classMembers})


def load_modules(root_path: Path, directory: Path):
    """
    Load all modules from a given directory relative to the root path.

    Args:
        root_path (Path): The root path of the application.
        directory (Path): The directory from which to load modules.

    """
    import importlib
    import pkgutil

    package_name: str = str(directory.relative_to(root_path).as_posix()).replace("/", ".")
    # Ensure package name starts with 'app.' for proper module resolution
    if not package_name.startswith("app."):
        package_name = f"app.{package_name}"

    for _, name, _ in pkgutil.iter_modules([directory]):
        full_name: str = f"{package_name}.{name}"
        if name.startswith("_"):
            continue

        try:
            importlib.import_module(full_name)
        except ImportError as e:
            LOG.error(f"Failed to import module '{full_name}': {e}")


def parse_tags(text: str) -> tuple[str, dict[str, str | bool]]:
    """
    Parse tags from a string formatted with %{tag_name[:value]}c.

    Args:
        text (str): The input string containing tags.

    Returns:
        tuple[str, dict[str, str | bool]]: A tuple containing the string with tags removed and a dictionary of tags.

    """
    tags: dict[str, str | bool] = {}

    def replacer(match: re.Match) -> str:
        name = match.group(1)
        value = match.group(2)
        tags[name] = value if value is not None else True
        return ""

    return TAG_REGEX.sub(replacer, text).strip(), tags


def str_to_dt(time_str: str, now=None) -> datetime:
    """
    Convert a string representation of time into a datetime object.

    Args:
        time_str (str): The string representation of time.
        now (datetime, optional): The base datetime to use for relative times. Defaults to None, which uses the current UTC time.

    Returns:
        datetime: A datetime object representing the parsed time.

    Raises:
        ValueError: If the time string cannot be parsed.

    """
    from dateparser import parse as _parse

    dt: datetime | None = _parse(
        time_str,
        settings={
            "RELATIVE_BASE": now or datetime.now(tz=UTC),
            "RETURN_AS_TIMEZONE_AWARE": True,
            "TO_TIMEZONE": "UTC",
        },
    )

    if dt is None:
        msg: str = f"Couldn't parse date: {time_str!r}"
        raise ValueError(msg)

    return dt


def list_folders(path: Path, base: Path, depth_limit: int) -> list[str]:
    """
    List all folders relative to a base path, up to a specified depth limit.

    Args:
        path (Path): The path to start listing folders from.
        base (Path): The base path to which the folders should be relative.
        depth_limit (int): The maximum depth to traverse from the base path.

    Returns:
        list[str]: A list of folder paths relative to the base path, up to the specified

    """
    if "/" == str(path):
        return []

    rel_depth: int = len(path.relative_to(base).parts)
    if rel_depth > depth_limit:
        return []

    folders: list[str] = []
    for entry in path.iterdir():
        if entry.is_dir():
            folders.append(str(entry.relative_to(base)))
            folders.extend(list_folders(entry, base, depth_limit))

    return folders


def get_channel_images(thumbnails: list[dict]) -> dict:
    """
    Extract channel images from a list of thumbnail dictionaries.

    Args:
        thumbnails (list[dict]): List of thumbnail dictionaries with keys 'url', 'width', 'height', and 'id'.

    Returns:
        dict: A dictionary with keys 'icon', 'landscape', 'poster', 'thumb', 'fanart', and 'banner' mapped to their respective URLs.

    """
    artwork = {}

    for t in thumbnails:
        url = t.get("url")
        if not url:
            continue

        width = t.get("width")
        height = t.get("height")
        tid = t.get("id", "")

        if not width or not height:
            if "avatar_uncropped" in tid:
                artwork["icon"] = url

            elif "banner_uncropped" in tid:
                artwork["landscape"] = url

            continue

        ratio = width / height

        if 0.55 <= ratio <= 0.75:  # portrait → poster
            artwork["poster"] = url
        elif 0.9 <= ratio <= 1.1:  # square → thumb
            artwork.setdefault("thumb", url)
        elif ratio >= 5:  # very wide
            if width >= 1920:
                artwork["fanart"] = url
            else:
                artwork["banner"] = url
        elif 1.6 <= ratio <= 1.8:  # landscape
            artwork["landscape"] = url

    if "fanart" not in artwork and "banner" in artwork:
        artwork["fanart"] = artwork["banner"]

    if "banner" not in artwork and "fanart" in artwork:
        artwork["banner"] = artwork["fanart"]

    if "poster" not in artwork and "thumb" in artwork:
        artwork["poster"] = artwork["thumb"]  # optional fallback

    return artwork


def create_cookies_file(cookies: str, file: Path | None = None) -> Path:
    """
    Create a cookies file from a string of cookies.

    Args:
        cookies (str): The cookie string.
        file (Path|None): The path to the cookie file. If None, a temporary file is created.

    Returns:
        Path: The path to the created cookie file.

    """
    if file is None:
        from .config import Config

        file = Path(Config.get_instance().temp_path, f"c_{uuid.uuid4().hex}.txt")

    file.parent.mkdir(parents=True, exist_ok=True)
    file.write_text(cookies)

    load_cookies(file)

    return file
