import logging
import os
import shutil
import tempfile

import requests
from tqdm import tqdm

logger = logging.getLogger()


class EngineDownloader:
    """A class responsible for downloading and extracting rendering engines from publicly available URLs.

    Attributes:
        supported_formats (list[str]): A list of file formats supported by the downloader.
    """

    supported_formats = ['.zip', '.tar.xz', '.dmg']

    def __init__(self):
        pass

    # --------------------------------------------
    # Required Overrides for Subclasses:
    # --------------------------------------------

    @classmethod
    def find_most_recent_version(cls, system_os=None, cpu=None, lts_only=False):
        """
        Finds the most recent version of the rendering engine available for download.

        This method should be overridden in a subclass to implement the logic for determining
        the most recent version of the rendering engine, optionally filtering by long-term
        support (LTS) versions, the operating system, and CPU architecture.

        Args:
            system_os (str, optional): Desired OS ('linux', 'macos', 'windows'). Defaults to system os.
            cpu (str, optional): The CPU architecture for which to download the engine. Default is system cpu.
            lts_only (bool, optional): Limit the search to LTS (long-term support) versions only. Default is False.

        Returns:
            dict: A dict with the following keys:
                - 'cpu' (str): The CPU architecture.
                - 'system_os' (str): The operating system.
                - 'file' (str): The filename of the version's download file.
                - 'url' (str): The remote URL for downloading the version.
                - 'version' (str): The version number.

        Raises:
            NotImplementedError: If the method is not overridden in a subclass.
        """
        raise NotImplementedError(f"find_most_recent_version not implemented for {cls.__class__.__name__}")

    @classmethod
    def version_is_available_to_download(cls, version, system_os=None, cpu=None):
        """Checks if a requested version of the rendering engine is available for download.

        This method should be overridden in a subclass to implement the logic for determining
        whether a given version of the rendering engine is available for download, based on the
        operating system and CPU architecture.

        Args:
            version (str): The requested renderer version to download.
            system_os (str, optional): Desired OS ('linux', 'macos', 'windows'). Defaults to system os.
            cpu (str, optional): The CPU architecture for which to download the engine. Default is system cpu.

        Returns:
            bool: True if the version is available for download, False otherwise.

        Raises:
            NotImplementedError: If the method is not overridden in a subclass.
        """
        raise NotImplementedError(f"version_is_available_to_download not implemented for {cls.__class__.__name__}")

    @classmethod
    def download_engine(cls, version, download_location, system_os=None, cpu=None, timeout=120, progress_callback=None):
        """Downloads the requested version of the rendering engine to the given download location.

        This method should be overridden in a subclass to implement the logic for downloading
        a specific version of the rendering engine. The method is intended to handle the
        downloading process based on the version, operating system, CPU architecture, and
        timeout parameters.

        Args:
            version (str): The requested renderer version to download.
            download_location (str): The directory where the engine should be downloaded.
            system_os (str, optional): Desired OS ('linux', 'macos', 'windows'). Defaults to system os.
            cpu (str, optional): The CPU architecture for which to download the engine. Default is system cpu.
            timeout (int, optional): The maximum time in seconds to wait for the download. Default is 120 seconds.
            progress_callback (callable, optional): A callback function that is called periodically with the current download progress.

        Raises:
            NotImplementedError: If the method is not overridden in a subclass.
        """
        raise NotImplementedError(f"download_engine not implemented for {cls.__class__.__name__}")

    # --------------------------------------------
    # Optional Overrides for Subclasses:
    # --------------------------------------------

    @classmethod
    def all_versions(cls, system_os=None, cpu=None):
        """Retrieves a list of available versions of the software for a specific operating system and CPU architecture.

        This method fetches all available versions for the given operating system and CPU type, constructing
        a list of dictionaries containing details such as the version, CPU architecture, system OS, and the
        remote URL for downloading each version.

        Args:
            system_os (str, optional): Desired OS ('linux', 'macos', 'windows'). Defaults to system os.
            cpu (str, optional): The CPU architecture for which to download the engine. Default is system cpu.

        Returns:
            list[dict]: A list of dictionaries, each containing:
                - 'cpu' (str): The CPU architecture.
                - 'file' (str): The filename of the version's download file.
                - 'system_os' (str): The operating system.
                - 'url' (str): The remote URL for downloading the version.
                - 'version' (str): The version number.
        """
        return []

    # --------------------------------------------
    # Do Not Override These Methods:
    # --------------------------------------------

    @classmethod
    def download_and_extract_app(cls, remote_url, download_location, timeout=120, progress_callback=None):
        """Downloads an application from the given remote URL and extracts it to the specified location.

        This method handles the downloading of the application, supports multiple archive formats,
        and extracts the contents to the specified `download_location`. It also manages temporary
        files and logs progress throughout the process.

        Args:
            remote_url (str): The URL of the application to download.
            download_location (str): The directory where the application should be extracted.
            timeout (int, optional): The maximum time in seconds to wait for the download. Default is 120 seconds.
            progress_callback (callable, optional): A callback function that is called periodically with the current download progress.

        Returns:
            str: The path to the directory where the application was extracted.

        Raises:
            Exception: Catches and logs any exceptions that occur during the download or extraction process.

        Supported Formats:
            - `.tar.xz`: Extracted using the `tarfile` module.
            - `.zip`: Extracted using the `zipfile` module.
            - `.dmg`: macOS disk image files, handled using the `dmglib` library.
            - Other formats will result in an error being logged.

        Notes:
            - If the application already exists in the `download_location`, the method will log an error
              and return without downloading or extracting.
            - Temporary files created during the download process are cleaned up after completion.
        """
        if progress_callback:
            progress_callback(0)

        # Create a temp download directory
        temp_download_dir = tempfile.mkdtemp()
        temp_downloaded_file_path = os.path.join(temp_download_dir, os.path.basename(remote_url))

        try:
            output_dir_name = os.path.basename(remote_url)
            for fmt in cls.supported_formats:
                output_dir_name = output_dir_name.split(fmt)[0]

            if os.path.exists(os.path.join(download_location, output_dir_name)):
                logger.error(f"Engine download for {output_dir_name} already exists")
                return None

            if not os.path.exists(temp_downloaded_file_path):
                # Make a GET request to the URL with stream=True to enable streaming
                logger.info(f"Downloading {output_dir_name} from {remote_url}")
                response = requests.get(remote_url, stream=True, timeout=timeout)

                # Check if the request was successful
                if response.status_code == 200:
                    # Get the total file size from the "Content-Length" header
                    file_size = int(response.headers.get("Content-Length", 0))

                    # Create a progress bar using tqdm
                    progress_bar = tqdm(total=file_size, unit="B", unit_scale=True)

                    # Open a file for writing in binary mode
                    total_saved = 0
                    with open(temp_downloaded_file_path, "wb") as file:
                        for chunk in response.iter_content(chunk_size=1024):
                            if chunk:
                                # Write the chunk to the file
                                file.write(chunk)
                                total_saved += len(chunk)
                                # Update the progress bar
                                progress_bar.update(len(chunk))
                                if progress_callback:
                                    percent = float(total_saved) / float(file_size)
                                    progress_callback(percent)

                    # Close the progress bar
                    progress_callback(1.0)
                    progress_bar.close()
                    logger.info(f"Successfully downloaded {os.path.basename(temp_downloaded_file_path)}")
                else:
                    logger.error(f"Failed to download the file. Status code: {response.status_code}")
                    return None

            os.makedirs(download_location, exist_ok=True)

            # Extract the downloaded file
            # Process .tar.xz files
            if temp_downloaded_file_path.lower().endswith('.tar.xz'):
                import tarfile
                try:
                    with tarfile.open(temp_downloaded_file_path, 'r:xz') as tar:
                        tar.extractall(path=download_location)

                    logger.info(
                        f'Successfully extracted {os.path.basename(temp_downloaded_file_path)} to {download_location}')
                except tarfile.TarError as e:
                    logger.error(f'Error extracting {temp_downloaded_file_path}: {e}')
                except FileNotFoundError:
                    logger.error(f'File not found: {temp_downloaded_file_path}')

            # Process .zip files
            elif temp_downloaded_file_path.lower().endswith('.zip'):
                import zipfile
                try:
                    with zipfile.ZipFile(temp_downloaded_file_path, 'r') as zip_ref:
                        zip_ref.extractall(download_location)
                    logger.info(
                        f'Successfully extracted {os.path.basename(temp_downloaded_file_path)} to {download_location}')
                except zipfile.BadZipFile:
                    logger.error(f'Error: {temp_downloaded_file_path} is not a valid ZIP file.')
                except FileNotFoundError:
                    logger.error(f'File not found: {temp_downloaded_file_path}')

            # Process .dmg files (macOS only)
            elif temp_downloaded_file_path.lower().endswith('.dmg'):
                import dmglib
                dmg = dmglib.DiskImage(temp_downloaded_file_path)
                for mount_point in dmg.attach():
                    try:
                        copy_directory_contents(mount_point, os.path.join(download_location, output_dir_name))
                        logger.info(f'Successfully copied {os.path.basename(temp_downloaded_file_path)} '
                                    f'to {download_location}')
                    except FileNotFoundError:
                        logger.error(f'Error: The source .app bundle does not exist.')
                    except PermissionError:
                        logger.error(f'Error: Permission denied to copy {download_location}.')
                    except Exception as e:
                        logger.error(f'An error occurred: {e}')
                dmg.detach()

            else:
                logger.error("Unknown file. Unable to extract binary.")

        except Exception as e:
            logger.exception(e)

        # remove downloaded file on completion
        shutil.rmtree(temp_download_dir)
        return download_location


# Function to copy directory contents but ignore symbolic links and hidden files
def copy_directory_contents(src_dir, dest_dir):
    try:
        # Create the destination directory if it doesn't exist
        os.makedirs(dest_dir, exist_ok=True)

        for item in os.listdir(src_dir):
            item_path = os.path.join(src_dir, item)

            # Ignore symbolic links
            if os.path.islink(item_path):
                continue

            # Ignore hidden files or directories (those starting with a dot)
            if not item.startswith('.'):
                dest_item_path = os.path.join(dest_dir, item)

                # If it's a directory, recursively copy its contents
                if os.path.isdir(item_path):
                    copy_directory_contents(item_path, dest_item_path)
                else:
                    # Otherwise, copy the file
                    shutil.copy2(item_path, dest_item_path)

    except PermissionError as ex:
        logger.error(f"Permissions error: {ex}")
    except Exception as e:
        logger.exception(f"Error copying directory contents: {e}")
