# -*- coding: utf-8 -*-
# Quasarr
# Project by https://github.com/rix1337

import json
import re
import socket
import sys
import traceback
import unicodedata
from base64 import urlsafe_b64decode, urlsafe_b64encode
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import date, datetime, timedelta
from io import BytesIO
from urllib.parse import urlparse

import requests
from PIL import Image

from quasarr.constants import (
    HOSTNAMES_REQUIRING_LOGIN,
    MONTHS_MAP,
    MOVIE_REGEX,
    SEARCH_CAT_BOOKS,
    SEARCH_CAT_MOVIES,
    SEARCH_CAT_MUSIC,
    SEARCH_CAT_SHOWS,
    SEASON_EP_REGEX,
)
from quasarr.providers.log import crit, debug, error, trace, warn
from quasarr.storage.categories import download_category_exists, search_category_exists


class Unbuffered(object):
    def __init__(self, stream):
        self.stream = stream

    def write(self, data):
        self.stream.write(data)
        self.stream.flush()

    def writelines(self, datas):
        self.stream.writelines(datas)
        self.stream.flush()

    def __getattr__(self, attr):
        return getattr(self.stream, attr)


def is_valid_url(url):
    """Validate if a URL is properly formatted."""
    parsed = urlparse(url)
    return parsed.scheme in ("http", "https") and bool(parsed.netloc)


def extract_allowed_keys(config, section):
    """
    Extracts allowed keys from the specified section in the configuration.

    :param config: The configuration dictionary.
    :param section: The section from which to extract keys.
    :return: A list of allowed keys.
    """
    if section not in config:
        raise ValueError(f"Section '{section}' not found in configuration.")
    return [key for key, *_ in config[section]]


def extract_kv_pairs(input_text, allowed_keys):
    """
    Extracts key-value pairs from the given text where keys match allowed_keys.

    :param input_text: The input text containing key-value pairs.
    :param allowed_keys: A list of allowed two-letter shorthand keys.
    :return: A dictionary of extracted key-value pairs.
    """
    kv_pattern = re.compile(rf"^({'|'.join(map(re.escape, allowed_keys))})\s*=\s*(.*)$")
    kv_pairs = {}

    for line in input_text.splitlines():
        match = kv_pattern.match(line.strip())
        if match:
            key, value = match.groups()
            kv_pairs[key] = value
        elif "[Hostnames]" in line:
            pass
        else:
            trace(
                f"Skipping line because it does not contain any supported hostname: {line}"
            )

    return kv_pairs


def check_ip():
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.connect(("10.255.255.255", 0))
        ip = s.getsockname()[0]
    except:
        ip = "127.0.0.1"
    finally:
        s.close()
    return ip


def check_flaresolverr(shared_state, flaresolverr_url):
    # Ensure it ends with /v<digit+>
    if not re.search(r"/v\d+$", flaresolverr_url):
        error(f"FlareSolverr URL does not end with /v#: {flaresolverr_url}")
        return False

    # Try sending a simple test request
    headers = {"Content-Type": "application/json"}
    data = {"cmd": "request.get", "url": "http://www.google.com/", "maxTimeout": 10000}

    try:
        response = requests.post(
            flaresolverr_url, headers=headers, json=data, timeout=10
        )
        response.raise_for_status()
        json_data = response.json()

        # Check if the structure looks like a valid FlareSolverr response
        if "status" in json_data and json_data["status"] == "ok":
            solution = json_data["solution"]
            solution_ua = solution.get("userAgent", None)
            if solution_ua:
                shared_state.update("user_agent", solution_ua)
            try:
                flaresolverr_version = json_data.get("version")
            except Exception as e:
                error(f"Could not grab Flaresolverr version: {str(e)}")
                return False
            return flaresolverr_version
        else:
            error(f"Unexpected FlareSolverr response: {json_data}")
            return False

    except Exception as e:
        error(f"Failed to connect to FlareSolverr: {e}")
        return False


def validate_address(address, name):
    if not address.startswith("http"):
        crit(f"Error: {name} '{address}' is invalid. It must start with 'http'.")
        sys.exit(1)

    colon_count = address.count(":")
    if colon_count < 1 or colon_count > 2:
        crit(
            f"Error: {name} '{address}' is invalid. It must contain 1 or 2 colons, but it has {colon_count}."
        )
        sys.exit(1)


def is_flaresolverr_available(shared_state):
    """
    Check if FlareSolverr is configured and available.

    Returns:
        bool: True if FlareSolverr URL is set and not skipped, False otherwise
    """
    # Check if FlareSolverr was skipped
    if shared_state.values["database"]("skip_flaresolverr").retrieve("skipped"):
        return False

    # Check if FlareSolverr URL is configured
    flaresolverr_url = shared_state.values["config"]("FlareSolverr").get("url")
    if not flaresolverr_url:
        return False

    return True


def is_site_usable(shared_state, shorthand):
    """
    Check if a site is fully configured and usable.

    For sites that don't require login, just checks if hostname is set.
    For login-required sites (al, dd, dl, nx), also checks that login wasn't skipped
    and that credentials exist.

    Args:
        shared_state: Shared state object
        shorthand: Site shorthand (e.g., 'al', 'dd', etc.)

    Returns:
        bool: True if site is usable, False otherwise
    """
    shorthand = shorthand.lower()

    # Check if hostname is set
    hostname = shared_state.values["config"]("Hostnames").get(shorthand)
    if not hostname:
        return False

    if shorthand not in HOSTNAMES_REQUIRING_LOGIN:
        return True  # No login needed, hostname is enough

    # Check if login was skipped
    if shared_state.values["database"]("skip_login").retrieve(shorthand):
        return False  # Hostname set but login was skipped

    # Check for credentials
    section = "JUNKIES" if shorthand in ["dj", "sj"] else shorthand.upper()
    config = shared_state.values["config"](section)
    user = config.get("user")
    password = config.get("password")

    return bool(user and password)


def generate_download_link(
    shared_state, title, url, size_mb, password, imdb_id, source_key
):
    """
    Generate a download link with a base64 encoded payload.
    The payload format is: title|url|size_mb|password|imdb_id|source_key

    Args:
        shared_state: Shared state object
        title: Release title
        url: Source URL
        size_mb: Size in MB (int or float)
        password: Password for the release (or empty string)
        imdb_id: IMDb ID (or None/empty string)
        source_key: Source shorthand (e.g., 'al', 'dd')

    Returns:
        str: Full download URL
    """
    # Ensure all fields are strings and handle None
    title = str(title) if title else ""
    url = str(url) if url else ""
    size_mb = str(size_mb) if size_mb is not None else "0"
    password = str(password) if password else ""
    imdb_id = str(imdb_id) if imdb_id else ""
    source_key = str(source_key) if source_key else ""

    raw_payload = f"{title}|{url}|{size_mb}|{password}|{imdb_id}|{source_key}"
    encoded_payload = urlsafe_b64encode(raw_payload.encode("utf-8")).decode("utf-8")

    return (
        f"{shared_state.values['internal_address']}/download/?payload={encoded_payload}"
    )


# =============================================================================
# LINK STATUS CHECKING
# =============================================================================


def generate_status_url(href, crypter_type):
    """
    Generate a status URL for crypters that support it.
    Returns None if status URL cannot be generated.
    """
    if crypter_type == "hide":
        # hide.cx links: https://hide.cx/folder/{UUID} or /container/{UUID} → https://hide.cx/state/{UUID}
        match = re.search(
            r"hide\.cx/(?:folder/|container/)?([a-f0-9-]{36})", href, re.IGNORECASE
        )
        if match:
            uuid = match.group(1)
            return f"https://hide.cx/state/{uuid}"

    elif crypter_type == "tolink":
        # tolink links: https://tolink.to/f/{ID} → https://tolink.to/f/{ID}/s/status.png
        match = re.search(r"tolink\.to/f/([a-zA-Z0-9]+)", href, re.IGNORECASE)
        if match:
            link_id = match.group(1)
            return f"https://tolink.to/f/{link_id}/s/status.png"

    return None


def detect_crypter_type(url):
    """Detect crypter type from URL for status checking."""
    url_lower = url.lower()
    if "hide." in url_lower:
        return "hide"
    elif "tolink." in url_lower:
        return "tolink"
    elif "filecrypt." in url_lower:
        return "filecrypt"
    elif "keeplinks." in url_lower:
        return "keeplinks"
    return None


def image_has_green(image_data):
    """
    Analyze image data to check if it contains green pixels.
    Returns True if any significant green is detected (indicating online status).
    """
    try:
        img = Image.open(BytesIO(image_data))
        # Convert palette images with transparency to RGBA first to avoid warning
        if img.mode == "P" and "transparency" in img.info:
            img = img.convert("RGBA")
        img = img.convert("RGB")

        pixels = list(img.getdata())

        for r, g, b in pixels:
            # Check if pixel is greenish: green channel is dominant
            # and has a reasonable absolute value
            if g > 100 and g > r * 1.3 and g > b * 1.3:
                return True

        return False
    except Exception:
        # If we can't analyze, assume online to not skip valid links
        return True


def fetch_status_image(status_url, shared_state=None):
    """
    Fetch a status image and return (status_url, image_data).
    Returns (status_url, None) on failure.
    """
    try:
        headers = {}
        if shared_state:
            user_agent = shared_state.values.get("user_agent")
            if user_agent:
                headers["User-Agent"] = user_agent
        response = requests.get(status_url, headers=headers, timeout=10)
        if response.status_code == 200:
            return (status_url, response.content)
    except Exception:
        pass
    return (status_url, None)


def check_links_online_status(links_with_status, shared_state=None):
    """
    Check online status for links that have status URLs.
    Returns list of links that are online (or have no status URL to check).

    links_with_status: list of [href, identifier, status_url] where status_url can be None
    shared_state: optional shared state for user agent
    """
    links_to_check = [(i, link) for i, link in enumerate(links_with_status) if link[2]]

    if not links_to_check:
        # No status URLs to check, return all links as potentially online
        return [[link[0], link[1]] for link in links_with_status]

    # Batch fetch status images
    status_results = {}  # status_url -> has_green
    status_urls = list(set(link[2] for _, link in links_to_check))

    batch_size = 10
    for i in range(0, len(status_urls), batch_size):
        batch = status_urls[i : i + batch_size]
        with ThreadPoolExecutor(max_workers=batch_size) as executor:
            futures = [
                executor.submit(fetch_status_image, url, shared_state) for url in batch
            ]
            for future in as_completed(futures):
                try:
                    status_url, image_data = future.result()
                    if image_data:
                        status_results[status_url] = image_has_green(image_data)
                    else:
                        # Could not fetch, assume online
                        status_results[status_url] = True
                except Exception:
                    pass

    # Filter to online links
    online_links = []

    for link in links_with_status:
        href, identifier, status_url = link
        if not status_url:
            # No status URL, include link
            online_links.append([href, identifier])
        elif status_url in status_results:
            if status_results[status_url]:
                online_links.append([href, identifier])
        else:
            # Status check failed, include link
            online_links.append([href, identifier])

    return online_links


def filter_offline_links(links, shared_state=None, log_func=None):
    """
    Filter out offline links from a list of [url, identifier] pairs.
    Only checks links where status can be verified (hide.cx, tolink).
    Returns filtered list of [url, identifier] pairs.
    """
    if not links:
        return links

    # Build list with status URLs
    links_with_status = []
    for link in links:
        url = link[0]
        identifier = link[1] if len(link) > 1 else "unknown"
        crypter_type = detect_crypter_type(url)
        status_url = generate_status_url(url, crypter_type) if crypter_type else None
        links_with_status.append([url, identifier, status_url])

    # Check if any links can be verified
    verifiable_count = sum(1 for l in links_with_status if l[2])
    if verifiable_count == 0:
        # Nothing to verify, return original links
        return links

    if log_func:
        log_func(f"Checking online status for {verifiable_count} verifiable link(s)...")

    # Check status and filter
    online_links = check_links_online_status(links_with_status, shared_state)

    if log_func and len(online_links) < len(links):
        offline_count = len(links) - len(online_links)
        log_func(f"Filtered out {offline_count} offline link(s)")

    return online_links


def parse_payload(payload_str):
    """
    Parse the base64-encoded payload string into its components.

    Format: title|url|size_mb|password|imdb_id|source_key

    Returns:
        dict with keys: title, url, size_mb, password, imdb_id, source_key
    """
    decoded = urlsafe_b64decode(payload_str.encode()).decode()
    parts = decoded.split("|")

    if len(parts) == 6:
        title, url, size_mb, password, imdb_id, source_key = parts
    else:
        raise ValueError(f"expected 6 fields, got {len(parts)}")

    return {
        "title": title,
        "url": url,
        "size_mb": size_mb,
        "password": password if password else None,
        "imdb_id": imdb_id if imdb_id else None,
        "source_key": source_key if source_key else None,
    }


def determine_category(request_from, category=None):
    """
    Determine the category based on the provided category or the client type.
    If category is not provided or invalid, falls back to default mapping based on request_from.
    """
    if category and download_category_exists(category):
        return category

    client_type = extract_client_type(request_from)
    # Default mapping
    category_map = {
        "lazylibrarian": "docs",
        "lidarr": "music",
        "radarr": "movies",
        "sonarr": "tv",
    }
    return category_map.get(client_type, "tv")


def get_base_search_category_id(cat_id):
    """
    Reverse-parses a custom or default category ID to its base type ID.
    E.g., 102010 -> 2000 (Movies), 5040 -> 5000 (TV)
    """
    try:
        cat_id = int(cat_id)
    except (ValueError, TypeError):
        return None

    # Custom categories are in the 100000+ range
    if cat_id >= 100000:
        # e.g. 102010 -> 2010 -> 2000
        # e.g. 103000 -> 3000 -> 3000
        base = (cat_id - 100000) // 1000 * 1000
        if base in [
            SEARCH_CAT_MOVIES,
            SEARCH_CAT_MUSIC,
            SEARCH_CAT_SHOWS,
            SEARCH_CAT_BOOKS,
        ]:
            return base

    # Standard categories and their subcategories
    elif 2000 <= cat_id < 3000:
        return SEARCH_CAT_MOVIES
    elif 3000 <= cat_id < 4000:
        return SEARCH_CAT_MUSIC
    elif 5000 <= cat_id < 6000:
        return SEARCH_CAT_SHOWS
    elif 7000 <= cat_id < 8000:
        return SEARCH_CAT_BOOKS

    return None


def determine_search_category(request_from, cat_param=None):
    """
    Determine the numeric search category based on the client type or cat parameter.
    Handles default and custom categories.
    """
    if cat_param:
        try:
            # Handle comma-separated categories (e.g. "5000,5030,5040")
            # We use the first valid one we can find a base for.
            cats = [int(c) for c in cat_param.split(",")]
            if len(cats) > 1:
                warn(
                    f"Only one category can be searched at once. You provided multiple: {cat_param}"
                )
            for cat in cats:
                if search_category_exists(cat):
                    return cat
        except ValueError:
            pass  # Fallback to user agent if cat param is invalid

    # Fallback to deriving from user agent
    client_type = extract_client_type(request_from)
    if client_type == "radarr":
        return SEARCH_CAT_MOVIES
    elif client_type == "lidarr":
        return SEARCH_CAT_MUSIC
    elif client_type == "lazylibrarian":
        return SEARCH_CAT_BOOKS
    elif client_type == "sonarr":
        return SEARCH_CAT_SHOWS
    else:
        warn(f"Unknown client type '{client_type}' from '{request_from}'")
        return None


def extract_client_type(request_from):
    """
    Extract client type from User-Agent, stripping version info.

    Examples:
        "Radarr/6.0.4.10291 (alpine 3.23.2)" → "radarr"
        "Sonarr/4.0.0.123" → "sonarr"
        "LazyLibrarian/1.0" → "lazylibrarian"
    """
    if not request_from:
        return "unknown"

    # Extract the client name before the version (first part before '/')
    client = request_from.split("/")[0].lower().strip()

    # Normalize known clients
    if "radarr" in client:
        return "radarr"
    elif "sonarr" in client:
        return "sonarr"
    elif "lidarr" in client:
        return "lidarr"
    elif "lazylibrarian" in client:
        return "lazylibrarian"

    return client


def convert_to_mb(item):
    size = float(item["size"])
    unit = item["sizeunit"].upper()

    if unit == "B":
        size_b = size
    elif unit == "KB":
        size_b = size * 1024
    elif unit == "MB":
        size_b = size * 1024 * 1024
    elif unit == "GB":
        size_b = size * 1024 * 1024 * 1024
    elif unit == "TB":
        size_b = size * 1024 * 1024 * 1024 * 1024
    else:
        raise ValueError(
            f"Unsupported size unit {item['name']} {item['size']} {item['sizeunit']}"
        )

    size_mb = size_b / (1024 * 1024)
    return int(size_mb)


def replace_umlauts(text):
    """
    Replace German umlauts with their ASCII equivalents.
    """
    replacements = {
        "ä": "ae",
        "ö": "oe",
        "ü": "ue",
        "Ä": "Ae",
        "Ö": "Oe",
        "Ü": "Ue",
        "ß": "ss",
    }

    for umlaut, replacement in replacements.items():
        text = text.replace(umlaut, replacement)

    return text


def sanitize_title(title: str) -> str:
    title = replace_umlauts(title)

    title = title.encode("ascii", errors="ignore").decode()

    # Replace slashes and spaces with dots
    title = title.replace("/", "").replace(" ", ".")
    title = title.strip(".")  # no leading/trailing dots
    title = title.replace(".-.", "-")  # .-. → -

    # Finally, drop any chars except letters, digits, dots, hyphens, ampersands
    title = re.sub(r"[^A-Za-z0-9.\-&]", "", title)

    # remove any repeated dots
    title = re.sub(r"\.{2,}", ".", title)
    return title


def sanitize_string(s):
    s = s.lower()

    # Remove dots / pluses
    s = s.replace(".", " ")
    s = s.replace("+", " ")
    s = s.replace("_", " ")
    s = s.replace("-", " ")

    # Umlauts
    s = replace_umlauts(s)

    # Remove special characters
    s = re.sub(r"[^a-zA-Z0-9\s]", "", s)

    # Remove season and episode patterns
    s = re.sub(r"\bs\d{1,3}(e\d{1,3})?\b", "", s)

    # Remove German and English articles
    articles = r"\b(?:der|die|das|ein|eine|einer|eines|einem|einen|the|a|an|and)\b"
    s = re.sub(articles, "", s, count=0, flags=re.IGNORECASE)

    # Replace obsolete titles
    s = s.replace("navy cis", "ncis")

    # Remove extra whitespace
    s = " ".join(s.split())

    return s


def search_string_in_sanitized_title(search_string, title):
    sanitized_search_string = sanitize_string(search_string)
    sanitized_title = sanitize_string(title)

    search_regex = r"\b.+\b".join(
        [re.escape(s) for s in sanitized_search_string.split(" ")]
    )
    # Use word boundaries to ensure full word/phrase match
    if re.search(rf"\b{search_regex}\b", sanitized_title):
        trace(f"Matched search string: {search_regex} with title: {sanitized_title}")
        return True
    else:
        trace(
            f"Skipping {title} as it doesn't match search string: {sanitized_search_string}"
        )
        return False


def is_imdb_id(search_string):
    if bool(re.fullmatch(r"tt\d{7,}", search_string)):
        return search_string
    else:
        return None


def match_in_title(title: str, season: int = None, episode: int = None) -> bool:
    # ensure season/episode are ints (or None)
    if isinstance(season, str):
        try:
            season = int(season)
        except ValueError:
            season = None
    if isinstance(episode, str):
        try:
            episode = int(episode)
        except ValueError:
            episode = None

    pattern = re.compile(
        r"(?i)(?:\.|^)[sS](\d+)(?:-(\d+))?"  # season or season‑range
        r"(?:[eE](\d+)(?:-(?:[eE]?)(\d+))?)?"  # episode or episode‑range
        r"(?=[\.-]|$)"
    )

    matches = pattern.findall(title)
    if not matches:
        return False

    for s_start, s_end, e_start, e_end in matches:
        se_start, se_end = int(s_start), int(s_end or s_start)

        # if a season was requested, ensure it falls in the range
        if season is not None and not (se_start <= season <= se_end):
            continue

        # if no episode requested, only accept if the title itself had no episode tag
        if episode is None:
            if not e_start:
                return True
            else:
                # title did specify an episode — skip this match
                continue

        # episode was requested, so title must supply one
        if not e_start:
            continue

        ep_start, ep_end = int(e_start), int(e_end or e_start)
        if ep_start <= episode <= ep_end:
            return True

    return False


def is_valid_release(
    title: str,
    search_category: int,
    search_string: str,
    season: int = None,
    episode: int = None,
) -> bool:
    """
    Return True if the given release title is valid for the given search parameters.
    - title: the release title to test
    - search_category: numeric search category, 2000 for movies, 5000 for tv shows
    - search_string: the original search phrase (could be an IMDb id or plain text)
    - season: desired season number (or None)
    - episode: desired episode number (or None)
    """
    try:
        is_movie_search = search_category == SEARCH_CAT_MOVIES
        is_tv_search = search_category == SEARCH_CAT_SHOWS
        is_docs_search = search_category == SEARCH_CAT_BOOKS
        is_music_search = search_category == SEARCH_CAT_MUSIC

        # if search string is NOT an imdb id check search_string_in_sanitized_title - if not match, it is not valid
        if not is_docs_search and not is_imdb_id(search_string):
            if not search_string_in_sanitized_title(search_string, title):
                trace(
                    "Skipping {title!r} as it doesn't match sanitized search string: {search_string!r}",
                    title=title,
                    search_string=search_string,
                )
                return False

        # if it's a movie search, don't allow any TV show titles (check for NO season or episode tags in the title)
        if is_movie_search:
            if not MOVIE_REGEX.match(title):
                trace(
                    "Skipping {title!r} as title doesn't match movie regex: {pattern!r}",
                    title=title,
                    pattern=MOVIE_REGEX.pattern,
                )
                return False
            return True

        # if it's a TV show search, don't allow any movies (check for season or episode tags in the title)
        if is_tv_search:
            # must have some S/E tag present
            if not SEASON_EP_REGEX.search(title):
                trace(
                    "Skipping {title!r} as title doesn't match TV show regex: {pattern!r}",
                    title=title,
                    pattern=SEASON_EP_REGEX.pattern,
                )
                return False
            # if caller specified a season or episode, double‑check the match
            if season is not None or episode is not None:
                if not match_in_title(title, season, episode):
                    trace(
                        "Skipping {title!r} as it doesn't match season {season} and episode {episode}",
                        title=title,
                        season=season,
                        episode=episode,
                    )
                    return False
            return True

        # if it's a document search, it should not contain Movie or TV show tags
        if is_docs_search:
            # must NOT have any S/E tag present
            if SEASON_EP_REGEX.search(title):
                trace(
                    "Skipping {title!r} as title matches TV show regex: {pattern!r}",
                    title=title,
                    pattern=SEASON_EP_REGEX.pattern,
                )
                return False
            return True

        # if it's a music search, it should not contain Movie or TV show tags
        if is_music_search:
            # must NOT have any S/E tag present
            if SEASON_EP_REGEX.search(title):
                trace(
                    "Skipping {title!r} as title matches TV show regex: {pattern!r}",
                    title=title,
                    pattern=SEASON_EP_REGEX.pattern,
                )
                return False
            return True

        # unknown search source — reject by default
        debug(f"Skipping {title!r} as search category is unknown: {search_category!r}")
        return False

    except Exception as e:
        # log exception message and short stack trace
        tb = traceback.format_exc()
        debug(
            f"Exception in is_valid_release: {e!r}\n{tb}"
            f"is_valid_release called with "
            f"title={title!r}, search_category={search_category!r}, "
            f"search_string={search_string!r}, season={season!r}, episode={episode!r}"
        )
        return False


def normalize_magazine_title(title: str) -> str:
    """
    Massage magazine titles so LazyLibrarian's parser can pick up dates reliably:
    - Convert date-like patterns into space-delimited numeric tokens (YYYY MM DD or YYYY MM).
    - Handle malformed "DD.YYYY.YYYY" cases (e.g., 04.2006.2025 → 2025 06 04).
    - Convert two-part month-year like "3.25" into YYYY MM.
    - Convert "No/Nr/Sonderheft X.YYYY" when X≤12 into YYYY MM.
    - Preserve pure issue/volume prefixes and other digit runs untouched.
    """
    title = title.strip()

    # 0) Bug: DD.YYYY.YYYY -> treat second YYYY's last two digits as month
    def repl_bug(match):
        d = int(match.group(1))
        m_hint = match.group(2)
        y = int(match.group(3))
        m = int(m_hint[-2:])
        try:
            date(y, m, d)
            return f"{y:04d} {m:02d} {d:02d}"
        except ValueError:
            return match.group(0)

    title = re.sub(r"\b(\d{1,2})\.(20\d{2})\.(20\d{2})\b", repl_bug, title)

    # 1) DD.MM.YYYY -> "YYYY MM DD"
    def repl_dmy(match):
        d, m, y = map(int, match.groups())
        try:
            date(y, m, d)
            return f"{y:04d} {m:02d} {d:02d}"
        except ValueError:
            return match.group(0)

    title = re.sub(r"\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b", repl_dmy, title)

    # 2) DD[.]? MonthName YYYY (optional 'vom') -> "YYYY MM DD"
    def repl_dmony(match):
        d = int(match.group(1))
        name = match.group(2)
        y = int(match.group(3))
        mm = MONTHS_MAP.get(name.lower())
        if mm:
            try:
                date(y, mm, d)
                return f"{y:04d} {mm:02d} {d:02d}"
            except ValueError:
                pass
        return match.group(0)

    title = re.sub(
        r"\b(?:vom\s*)?(\d{1,2})\.?\s+([A-Za-zÄÖÜäöüß]+)\s+(\d{4})\b",
        repl_dmony,
        title,
        flags=re.IGNORECASE,
    )

    # 3) MonthName YYYY -> "YYYY MM"
    def repl_mony(match):
        name = match.group(1)
        y = int(match.group(2))
        mm = MONTHS_MAP.get(name.lower())
        if mm:
            try:
                date(y, mm, 1)
                return f"{y:04d} {mm:02d}"
            except ValueError:
                pass
        return match.group(0)

    title = re.sub(
        r"\b([A-Za-zÄÖÜäöüß]+)\s+(\d{4})\b", repl_mony, title, flags=re.IGNORECASE
    )

    # 4) YYYYMMDD -> "YYYY MM DD"
    def repl_ymd(match):
        y = int(match.group(1))
        m = int(match.group(2))
        d = int(match.group(3))
        try:
            date(y, m, d)
            return f"{y:04d} {m:02d} {d:02d}"
        except ValueError:
            return match.group(0)

    title = re.sub(
        r"\b(20\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\b", repl_ymd, title
    )

    # 5) YYYYMM -> "YYYY MM"
    def repl_ym(match):
        y = int(match.group(1))
        m = int(match.group(2))
        try:
            date(y, m, 1)
            return f"{y:04d} {m:02d}"
        except ValueError:
            return match.group(0)

    title = re.sub(r"\b(20\d{2})(0[1-9]|1[0-2])\b", repl_ym, title)

    # 6) X.YY (month.two-digit-year) -> "YYYY MM" (e.g., 3.25 -> 2025 03)
    def repl_my2(match):
        mm = int(match.group(1))
        yy = int(match.group(2))
        y = 2000 + yy
        if 1 <= mm <= 12:
            try:
                date(y, mm, 1)
                return f"{y:04d} {mm:02d}"
            except ValueError:
                pass
        return match.group(0)

    title = re.sub(r"\b([1-9]|1[0-2])\.(\d{2})\b", repl_my2, title)

    # 7) No/Nr/Sonderheft <1-12>.<YYYY> -> "YYYY MM"
    def repl_nmy(match):
        num = int(match.group(1))
        y = int(match.group(2))
        if 1 <= num <= 12:
            try:
                date(y, num, 1)
                return f"{y:04d} {num:02d}"
            except ValueError:
                pass
        return match.group(0)

    title = re.sub(
        r"\b(?:No|Nr|Sonderheft)\s*(\d{1,2})\.(\d{4})\b",
        repl_nmy,
        title,
        flags=re.IGNORECASE,
    )

    return title


def get_recently_searched(shared_state, context, timeout_seconds):
    recently_searched = shared_state.values.get(context, {})
    threshold = datetime.now() - timedelta(seconds=timeout_seconds)
    keys_to_remove = [
        key
        for key, value in recently_searched.items()
        if value["timestamp"] <= threshold
    ]
    for key in keys_to_remove:
        debug(f"Removing '{key}' from recently searched memory ({context})...")
        del recently_searched[key]
    return recently_searched


def download_package(links, title, password, package_id, shared_state):
    links = [sanitize_url(link) for link in links]

    device = shared_state.get_device()
    downloaded = device.linkgrabber.add_links(
        params=[
            {
                "autostart": False,
                "links": json.dumps(links),
                "packageName": title,
                "extractPassword": password,
                "priority": "DEFAULT",
                "downloadPassword": password,
                "destinationFolder": "Quasarr/<jd:packagename>",
                "comment": package_id,
                "overwritePackagizerRules": True,
            }
        ]
    )
    return downloaded


def sanitize_url(url: str) -> str:
    # normalize first
    url = unicodedata.normalize("NFKC", url)

    # 1) real control characters (U+0000–U+001F, U+007F–U+009F)
    _REAL_CTRL_RE = re.compile(r"[\u0000-\u001f\u007f-\u009f]")

    # 2) *literal* escaped unicode junk: \u0010, \x10, repeated variants
    _ESCAPED_CTRL_RE = re.compile(r"(?:\\u00[0-1][0-9a-fA-F]|\\x[0-1][0-9a-fA-F])")

    # remove literal escaped control sequences like "\u0010"
    url = _ESCAPED_CTRL_RE.sub("", url)

    # remove actual control characters if already decoded
    url = _REAL_CTRL_RE.sub("", url)

    return url.strip()
