#!/usr/bin/python3


from __future__ import annotations

import logging
import shlex
import shutil
import threading
from pathlib import Path
from threading import Thread
from typing import Callable, Optional, Union
from urllib.parse import unquote, urlparse

from slpkg.config import config_load
from slpkg.errors import Errors
from slpkg.multi_process import MultiProcess
from slpkg.repositories import Repositories
from slpkg.utilities import Utilities
from slpkg.views.display import View

logger = logging.getLogger(__name__)


class Downloader:  # pylint: disable=[R0902]
    """Download the sources using external tools."""

    def __init__(self, options: dict[str, bool]) -> None:
        logger.debug("Initializing Downloader class with options: %s", options)
        self.options = options

        self.downloader = config_load.downloader
        self.maximum_parallel = config_load.maximum_parallel
        self.parallel_downloads = config_load.parallel_downloads

        self.wget_options = config_load.wget_options
        self.curl_options = config_load.curl_options
        self.aria2_options = config_load.aria2_options
        self.lftp_get_options = config_load.lftp_get_options
        self.red = config_load.red
        self.green = config_load.green
        self.endc = config_load.endc

        self.errors = Errors()
        self.utils = Utilities()
        self.multi_process = MultiProcess(options)
        self.views = View(options)
        self.repos = Repositories()

        self.repo_data: list[str] = []  # Set once in download(); read-only during parallel execution.
        self.sbo_locations: dict[str, str] = {}  # Maps sbo name → location for per-package fallback URL.

        # Each downloader function takes (url, path, filename) and returns the command as a list of arguments.
        self.downloader_tools: dict[str, Callable[[str, Path, str], list[str]]] = {
            "wget": self.set_wget_downloader,
            "wget2": self.set_wget_downloader,  # wget2 uses similar options to wget for this purpose.
            "curl": self.set_curl_downloader,
            "aria2c": self.set_aria2_downloader,
            "lftp": self.set_lftp_downloader,
        }

        logger.debug(
            "Downloader class initialized. Configured downloader: '%s', Parallel downloads: %s, Max parallel: %d",
            self.downloader,
            self.parallel_downloads,
            self.maximum_parallel,
        )

    def download(
        self,
        sources: dict[str, tuple[Union[list[str], tuple[str, ...]], Path]],
        repo_data: Optional[list[str]] = None,
        sbo_locations: Optional[dict[str, str]] = None,
    ) -> None:
        """Start the process for downloading.

        Args:
            sources: A dictionary where keys are repository names and values are tuples containing:
                - A list of URLs (for source files) or a tuple of URLs (for binary repo files like changelog, packages, checksums).
                - The Path where the files should be saved.
            repo_data: Optional list containing [repo_name] for SBo repos. Used for fallback mirror logic.
            sbo_locations: Optional mapping of sbo name → repository location for per-package fallback URLs.
        """
        logger.info("Initiating download process. Parallel mode: %s", self.parallel_downloads)
        if repo_data is not None:
            self.repo_data = repo_data
            logger.debug("Repository data provided for download: %s", self.repo_data)
        if sbo_locations is not None:
            self.sbo_locations = sbo_locations
            logger.debug("SBo locations map provided with %d entries.", len(self.sbo_locations))

        if self.parallel_downloads:
            logger.debug("Calling parallel_download method.")
            self.parallel_download(sources)
        else:
            logger.debug("Calling normal_download method.")
            self.normal_download(sources)
        logger.info("Download process completed.")

    def parallel_download(self, sources: dict[str, tuple[Union[list[str], tuple[str, ...]], Path]]) -> None:
        """Download sources with parallel mode.

        Uses threads (not processes): downloads are I/O-bound subprocess calls, so the GIL
        is released during subprocess.run() and true concurrency is achieved without the
        overhead of forking the interpreter. A BoundedSemaphore limits concurrency to
        ``maximum_parallel`` simultaneous downloads. Each thread works with its own local
        state (filename, command, repo_data copy) so no shared-state races occur.

        Args:
            sources: A dictionary where keys are repository names and values are tuples containing:
                - A list of URLs (for source files) or a tuple of URLs (for binary repo files like changelog, packages, checksums).
                - The Path where the files should be saved.
        """
        max_workers: int = int(self.maximum_parallel)
        semaphore = threading.BoundedSemaphore(max_workers)
        logger.info("Starting parallel download for %d source sets (max_workers=%d).", len(sources), max_workers)

        def _download(url: str, path: Path) -> None:
            with semaphore:
                self.tools(url, path)

        threads: list[Thread] = []
        for urls_or_tuple, path in sources.values():
            for url in urls_or_tuple:
                t = Thread(target=_download, args=(url, path))
                threads.append(t)
                t.start()
                logger.debug("Started thread for download: %s", url)

        for t in threads:
            t.join()
        logger.info("All parallel download threads finished.")

    def normal_download(self, sources: dict[str, tuple[Union[list[str], tuple[str, ...]], Path]]) -> None:
        """Download sources with normal mode (sequential).

        Args:
            sources: A dictionary where keys are repository names and values are tuples containing:
                - A list of URLs (for source files) or a tuple of URLs (for binary repo files like changelog, packages, checksums).
                - The Path where the files should be saved.
        """
        logger.info("Starting normal (sequential) download for %d source sets.", len(sources))
        for urls_or_tuple, path in sources.values():
            for url in urls_or_tuple:
                logger.debug("Downloading sequentially: %s to %s", url, path)
                self.tools(url, path)
        logger.info("Normal download process finished.")

    @staticmethod
    def _is_local_url(url: str) -> bool:
        """Return True for file:// URIs and absolute filesystem paths.

        Args:
            url (str): URL or path to test.
        """
        return url.startswith("file://") or (url.startswith("/") and not url.startswith("//"))

    def _copy_local_file(self, url: str, path: Path) -> None:
        """Copy a local file (file:// URI or absolute path) to the destination directory.

        Args:
            url (str): file:// URI or absolute filesystem path.
            path (Path): Destination directory.
        """
        src = Path(url[len("file://"):] if url.startswith("file://") else url)
        filename = src.name
        logger.info("Copying local file '%s' to '%s'.", src, path / filename)
        print(f"  {'Copying':<11}  {filename} ", end="", flush=True)
        try:
            shutil.copy2(src, path / filename)
            print(f"{self.green}done{self.endc}")
            logger.info("Local file '%s' successfully copied.", filename)
        except FileNotFoundError:
            print(f"{self.red}failed{self.endc}")
            logger.error("Local file not found: '%s'.", src)
            self._handle_download_failure(filename, url)
        except OSError as e:
            print(f"{self.red}failed{self.endc}")
            logger.error("Failed to copy local file '%s': %s", src, e)
            self._handle_download_failure(filename, url)

    def tools(self, url: str, path: Path) -> None:
        """Run the selected downloader tool.

        All per-download state (filename, command, repo_data) is kept in local variables
        so this method is safe to call concurrently from multiple threads.

        Args:
            url (str): The URL link to download.
            path (Path): Path to save the downloaded file.
        """
        logger.info("Preparing to download URL: '%s' to path: '%s'", url, path)

        if self._is_local_url(url):
            self._copy_local_file(url, path)
            return

        # Local copy of repo_data so we can adjust the location without mutating shared state.
        repo_data = list(self.repo_data)
        if self.sbo_locations and repo_data:
            location = self.sbo_locations.get(path.name)
            if location:
                repo_data = [repo_data[0], location]
                logger.debug("Updated repo_data location for '%s': %s", path.name, location)

        filename = unquote(Path(urlparse(url).path).name)
        logger.debug("Extracted filename: '%s' from URL: '%s'", filename, url)

        downloader_func = self.downloader_tools.get(self.downloader)
        if downloader_func:
            downloader_command = downloader_func(url, path, filename)
            logger.debug("Downloader command set: '%s'", downloader_command)
        else:
            logger.critical("Configured downloader '%s' is not supported. Exiting.", self.downloader)
            self.errors.message(f"Downloader '{self.downloader}' not supported", exit_status=1)

        logger.info("Executing download command: '%s'", downloader_command)
        self.multi_process.process(downloader_command)
        logger.info("Download command execution finished. Checking if file was downloaded.")

        self.check_if_downloaded(path, filename, repo_data)
        logger.debug("Checked download status for '%s'.", filename)

    def set_wget_downloader(self, url: str, path: Path, _filename: str) -> list[str]:
        """Return the wget/wget2 download command as a list of arguments.

        Args:
            url (str): URL link.
            path (Path): Path to save.
            _filename (str): Extracted filename from URL (unused by wget).
        """
        command = [self.downloader, *shlex.split(self.wget_options), f"--directory-prefix={path}", url]
        logger.debug("Wget command set: %s", command)
        return command

    def set_curl_downloader(self, url: str, path: Path, filename: str) -> list[str]:
        """Return the curl download command as a list of arguments.

        Args:
            url (str): URL link.
            path (Path): Path to save.
            filename (str): Extracted filename used to build the --output path.
        """
        command = [self.downloader, *shlex.split(self.curl_options), url, "--output", str(path / filename)]
        logger.debug("Curl command set: %s", command)
        return command

    def set_aria2_downloader(self, url: str, path: Path, _filename: str) -> list[str]:
        """Return the aria2c download command as a list of arguments.

        Args:
            url (str): URL link.
            path (Path): Path to save.
            _filename (str): Extracted filename from URL (unused by aria2c).
        """
        command = [self.downloader, *shlex.split(self.aria2_options), f"--dir={path}", url]
        logger.debug("Aria2c command set: %s", command)
        return command

    def set_lftp_downloader(self, url: str, path: Path, _filename: str) -> list[str]:
        """Return the lftp download command as a list of arguments.

        Args:
            url (str): URL link.
            path (Path): Path to save.
            _filename (str): Extracted filename from URL (unused by lftp).
        """
        command = [self.downloader, *shlex.split(self.lftp_get_options), url, "-o", str(path)]
        logger.debug("LFTP command set: %s", command)
        return command

    def check_if_downloaded(self, path: Path, filename: str, repo_data: list[str]) -> None:
        """Check if the file was successfully downloaded.

        If not, attempts to re-download from sbosrcarch_mirror if applicable.

        Args:
            path (Path): Directory where the file should have been saved.
            filename (str): Expected filename.
            repo_data (list[str]): Repository data for the current download (thread-local copy).
        """
        path_file: Path = path / filename
        logger.debug("Checking if downloaded file exists: %s", path_file)

        if path_file.exists():
            logger.info("File '%s' successfully downloaded to '%s'.", filename, path_file)
            return

        logger.warning("File '%s' was not found after initial download attempt.", filename)

        if not self._can_attempt_sbosrcarch_fallback(repo_data):
            logger.debug("SBoSrcArch mirror not enabled or repo_data insufficient for fallback. Skipping.")
            self._handle_download_failure(filename, "N/A")
            return

        downloader_func = self.downloader_tools.get(self.downloader)
        if not downloader_func:
            logger.critical(
                "Configured downloader '%s' is not supported for fallback. Cannot attempt re-download.",
                self.downloader,
            )
            self._handle_download_failure(filename, "N/A")
            return

        fallback_url = self._construct_sbosrcarch_url(path, filename, repo_data)
        logger.info("Attempting re-download from SBoSrcArch mirror: '%s'.", fallback_url)
        self._attempt_fallback_download(fallback_url, path, filename, downloader_func)

    def _attempt_fallback_download(
        self, fallback_url: str, path: Path, filename: str, downloader_func: Callable[[str, Path, str], list[str]]
    ) -> None:
        """Execute the fallback download and verify the result.

        Args:
            fallback_url (str): The SBoSrcArch mirror URL to download from.
            path (Path): Directory where the file should be saved.
            filename (str): Expected filename.
            downloader_func: The downloader callable that builds the command.
        """
        try:
            fallback_command = downloader_func(fallback_url, path, filename)
            logger.debug("Fallback downloader command: '%s'", fallback_command)
            self.multi_process.process(fallback_command)
            logger.info("Fallback download command execution finished.")
        except Exception as e:  # pylint: disable=[W0718]
            logger.error("Fallback download raised an exception: %s", e, exc_info=True)
            self._handle_download_failure(filename, fallback_url)
            return

        if not (path / filename).exists():
            logger.error("File '%s' still not found after fallback download attempt.", filename)
            self._handle_download_failure(filename, fallback_url)
        else:
            logger.info("File '%s' successfully downloaded via fallback.", filename)

    def _can_attempt_sbosrcarch_fallback(self, repo_data: list[str]) -> bool:
        """Determine if a fallback download from sbosrcarch_mirror is applicable.

        Args:
            repo_data (list[str]): Repository data for the current download (thread-local copy).
        """
        is_sbo_or_ponce_repo = len(repo_data) > 1 and str(repo_data[0]) in {
            self.repos.sbo_repo_name,
            self.repos.ponce_repo_name,
        }

        can_fallback = bool(self.repos.sbosrcarch_mirror and is_sbo_or_ponce_repo)
        logger.debug(
            "Can attempt SBoSrcArch fallback: %s (Mirror enabled: %s, Is SBo/Ponce repo: %s)",
            can_fallback,
            bool(self.repos.sbosrcarch_mirror),
            is_sbo_or_ponce_repo,
        )
        return can_fallback

    def _construct_sbosrcarch_url(self, path: Path, filename: str, repo_data: list[str]) -> str:
        """Construct the fallback URL for sbosrcarch_mirror.

        Args:
            path (Path): Directory path used to derive the SlackBuild name.
            filename (str): Filename to append to the fallback URL.
            repo_data (list[str]): Repository data for the current download (thread-local copy).
        """
        location = str(repo_data[1])
        sbo_dir = path.name
        fallback_url = f"{self.repos.sbosrcarch_mirror}{location}/{sbo_dir}/{filename}"
        logger.debug("Constructed SBoSrcArch fallback URL: %s", fallback_url)
        return fallback_url

    def _handle_download_failure(self, filename: str, url_attempted: str) -> None:
        """Handle the final failure of a download, printing a message and prompting the user."""
        logger.critical(
            "Final download failure for file '%s'. Attempted URL: '%s'. Prompting user for action.",
            filename,
            url_attempted,
        )
        print(f"\n{self.red}Failed{self.endc}: Download the file: '{filename}'\n")
        self.views.question()
