#!/usr/bin/python3


from __future__ import annotations

import bz2
import gzip
import json
import logging
import re
import shlex
import shutil
import subprocess
from pathlib import Path
from subprocess import CompletedProcess
from typing import Union

from slpkg.check_updates import CheckUpdates
from slpkg.config import config_load
from slpkg.downloader import Downloader
from slpkg.install_data import InstallData
from slpkg.multi_process import MultiProcess
from slpkg.repositories import Repositories
from slpkg.sbo.generate import SBoGenerate
from slpkg.utilities import Utilities
from slpkg.views.display import View
from slpkg.views.process import ViewProcess

logger = logging.getLogger(__name__)


class UpdateRepositories:  # pylint: disable=[R0902]
    """Update the local repositories."""

    def __init__(self, options: dict[str, bool], repository: str) -> None:
        logger.debug("Initializing UpdateRepositories module with options: %s, repository: %s", options, repository)
        self.gpg_verification = config_load.gpg_verification
        self.ask_question = config_load.ask_question
        self.git_clone = config_load.git_clone
        self.green = config_load.green
        self.red = config_load.red
        self.endc = config_load.endc
        self.lftp_mirror_options = config_load.lftp_mirror_options
        self.is_64bit: bool = config_load.is_64bit()
        self.cpu_arch: str = config_load.cpu_arch
        self.view_process = ViewProcess()

        self.view = View(options)
        self.multi_process = MultiProcess(options)
        self.repos = Repositories()
        self.utils = Utilities()
        self.data = InstallData()
        self.generate = SBoGenerate()
        self.check_updates = CheckUpdates(options, repository)
        self.download = Downloader(options)

        self.repos_for_update: dict[str, bool] = {}
        logger.debug(
            "UpdateRepositories module initialized. GPG verification: %s, Git clone command: %s",
            self.gpg_verification,
            self.git_clone,
        )

    @staticmethod
    def _is_local_mirror(url: str) -> bool:
        """Return True if the mirror URL refers to a local filesystem path.

        Args:
            url (str): Mirror URL or path to check.

        Returns:
            bool: True for file:// URIs or absolute paths, False for remote URLs.
        """
        return url.startswith("file://") or url.startswith("/")

    def _copy_local_repo_files(self, repo: str, file_urls: list[str]) -> None:
        """Copy repository index files from a local mirror into the local cache.

        Args:
            repo (str): Repository name.
            file_urls (list[str]): Source URLs (file:// URIs or absolute paths).
        """
        dest: Path = self.repos.repositories[repo]["path"]
        for url in file_urls:
            src = Path(url[len("file://"):] if url.startswith("file://") else url)
            if src.is_file():
                shutil.copy2(src, dest / src.name)
                logger.info("Copied local mirror file '%s' to '%s'.", src, dest / src.name)
            else:
                logger.warning("Local mirror file '%s' not found. Skipping.", src)

    def repositories(self) -> None:
        """Check and call the repositories for update."""
        logger.info("Checking for repository updates.")
        self.repos_for_update = self.check_updates.updates()

        if not any(self.repos_for_update.values()):
            logger.info("No repositories require update based on checksums. Asking user for force update.")
            self.view.question(message="Do you want to force update?")
            # Force update the repositories.
            for repo in self.repos_for_update:
                self.repos_for_update[repo] = True
            logger.info("All repositories marked for forced update.")
        else:
            logger.info(
                "The following repositories require update: %s", {k: v for k, v in self.repos_for_update.items() if v}
            )

        self.run_update()
        logger.info("Repository update process completed.")

    @staticmethod
    def _fetch_gpg_key(gpg_key_url: str) -> CompletedProcess[str]:
        """Run gpg --fetch-key for the given URL.

        Args:
            gpg_key_url (str): Full URL to the GPG key file.

        Returns:
            CompletedProcess: Result of the subprocess call.

        Raises:
            subprocess.CalledProcessError: If gpg exits with a non-zero status.
        """
        return subprocess.run(
            ["gpg", "--fetch-key", gpg_key_url],
            shell=False,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            encoding="utf-8",
            text=True,
            check=True,
        )

    def import_gpg_key(self, repo: str) -> None:
        """Import the GPG KEY for a given repository.

        Args:
            repo (str): Repository name.
        """
        if not self.gpg_verification:
            logger.info("GPG verification is disabled. Skipping GPG key import for repository: %s", repo)
            return

        logger.debug("Attempting to import GPG key for repository: %s", repo)
        mirror: str = self.repos.repositories[repo]["mirror_changelog"]

        if self._is_local_mirror(mirror):
            logger.info("Skipping GPG key import for local mirror repository: %s", repo)
            return

        if repo == self.repos.sbo_repo_name:
            mirror = "https://www.slackbuilds.org/"
            logger.debug("Adjusting GPG key mirror for SBo repository to: %s", mirror)

        gpg_key: str = f"{mirror}GPG-KEY"
        logger.debug("Attempting to fetch GPG key from %s", gpg_key)

        try:
            process = self._fetch_gpg_key(gpg_key)
            logger.info("GPG key fetch attempt 1 successful for %s. Output: %s", gpg_key, process.stdout.strip())
            self._getting_gpg_print(process, mirror)
        except subprocess.CalledProcessError as e:
            logger.warning(
                "GPG key fetch attempt 1 failed for %s. Error: %s. Output: %s", gpg_key, e, e.stdout.strip()
            )
            # Fallback to packages mirror if changelog mirror fails.
            mirror = self.repos.repositories[repo]["mirror_packages"]
            gpg_key = f"{mirror}GPG-KEY"
            logger.debug("Trying fallback mirror: %s", gpg_key)
            try:
                process = self._fetch_gpg_key(gpg_key)
                logger.info(
                    "GPG key fetch attempt 2 successful for %s. Output: %s", gpg_key, process.stdout.strip()
                )
                self._getting_gpg_print(process, mirror)
            except subprocess.CalledProcessError:
                logger.error(
                    "GPG key fetch attempt 2 failed for %s. Error: %s. Output: %s", gpg_key, e, e.stdout.strip()
                )
                print(f"Getting GPG key: {self.red}Failed{self.endc}")
                self.view.question()
                logger.critical("GPG key import failed after all attempts. Application may exit.")

    @staticmethod
    def _getting_gpg_print(process: CompletedProcess[str], mirror: str) -> None:
        """Print the gpg mirror and log GPG key import status.

        Args:
            process: Subprocess process output.
            mirror: The GPG key mirror URL.
        """
        output: list[str] = re.split(r"/|\s", process.stdout)
        if "imported:" in output:
            print(f"Getting GPG key from: {mirror}\n")
            logger.info("GPG key successfully imported from: %s", mirror)
        else:
            # This case might indicate a successful run but no 'imported' message, or an unexpected output
            logger.warning(
                "GPG key import status unclear for %s. Return code: %s, Output: %s",
                mirror,
                process.returncode,
                process.stdout.strip(),
            )

    def run_update(self) -> None:
        """Update the repositories by category (binary or SlackBuilds)."""
        logger.info("Starting repository update execution.")
        for repo, update in self.repos_for_update.items():
            if update:
                logger.info("Updating repository: %s", repo)
                self.view_downloading_message(repo)
                if repo in {self.repos.sbo_repo_name, self.repos.ponce_repo_name}:
                    logger.debug("Repository '%s' is a SlackBuilds repository. Calling update_slackbuild_repos.", repo)
                    self.update_slackbuild_repos(repo)
                else:
                    logger.debug("Repository '%s' is a binary repository. Calling update_binary_repos.", repo)
                    self.update_binary_repos(repo)
            else:
                logger.info("Skipping repository '%s' as it does not require an update.", repo)

    def view_downloading_message(self, repo: str) -> None:
        """Print the syncing message to the console.

        Args:
            repo (str): Repository name.
        """
        print(f"Syncing with the repository '{self.green}{repo}{self.endc}', please wait...\n")

    def update_binary_repos(self, repo: str) -> None:  # pylint: disable=[R0914]
        """Update the binary repositories by downloading changelog, packages, and checksums.

        Args:
            repo (str): Repository name.
        """
        urls: dict[str, tuple[Union[list[str], tuple[str, ...]], Path]] = {}

        self.import_gpg_key(repo)

        repo_cfg = self.repos.repositories[repo]

        # Construct URLs for changelog, packages, and checksums.
        mirror_cl: str = repo_cfg["mirror_changelog"]
        mirror_pk: str = repo_cfg["mirror_packages"]

        changelog, packages, checksums, manifest = [
            f"{mirror}{file}"
            for mirror, file in (
                (mirror_cl, repo_cfg["changelog_txt"]),
                (mirror_pk, repo_cfg["packages_txt"]),
                (mirror_pk, repo_cfg["checksums_md5"]),
                (mirror_pk, repo_cfg["manifest_bz2"]),
            )
        ]

        if repo == self.repos.slack_repo_name:
            arch: str = "64" if self.is_64bit and self.cpu_arch != "aarch64" else ""
            manifest = f"{repo_cfg['mirror_packages']}slackware{arch}/{repo_cfg['manifest_bz2']}"

        logger.debug("Binary repo URLs: Changelog=%s, Packages=%s, Checksums=%s", changelog, packages, checksums)

        checksums_path: Path = repo_cfg["path"] / repo_cfg["checksums_md5"]
        has_manifest = checksums_path.exists() and self.repos.manifest_bz2 in checksums_path.read_text(encoding="utf-8")

        base_files = [changelog, packages, checksums]
        if has_manifest:
            base_files.append(manifest)

        urls[repo] = (tuple(base_files), repo_cfg["path"])

        self.utils.create_directory(repo_cfg["path"])

        if self._is_local_mirror(repo_cfg["mirror_packages"]):
            logger.info("Local mirror detected for '%s'. Copying files directly.", repo)
            self._copy_local_repo_files(repo, base_files)
        else:
            for file in ("changelog_txt", "packages_txt", "checksums_md5", "manifest_bz2"):
                self.utils.remove_file_if_exists(repo_cfg["path"], repo_cfg[file])
            logger.info("Downloading repository data for %s...", repo)
            self.download.download(urls)

        repo_path: Path = repo_cfg["path"]
        packages_txt: Path = repo_path / repo_cfg["packages_txt"]

        if not packages_txt.exists():
            logger.error("Essential file PACKAGES.TXT missing for %s. Skipping update.", repo)
            print(f"{self.red}Error:{self.endc} Could not find PACKAGES.TXT for {repo}. Repository update failed.")
            return

        print()
        self.view_process.message(f"Creating file search index for {repo}")
        self.update_filelist_index(repo_cfg["path"], self.repos.manifest_bz2)
        self.view_process.done()
        logger.info("File search index installation completed for %s.", repo)
        self.data.install_binary_data(repo)
        logger.info("Binary data installation completed for %s.", repo)

    def update_slackbuild_repos(self, repo: str) -> None:
        """Update the SlackBuild repositories using Git or LFTP.

        Args:
            repo (str): Repository name.
        """
        logger.info("Updating SlackBuild repository: %s", repo)
        self.import_gpg_key(repo)

        mirror: str = self.repos.repositories[repo]["mirror_packages"]

        git_mirror: dict[str, str] = {
            self.repos.sbo_repo_name: self.repos.sbo_git_mirror,
            self.repos.ponce_repo_name: self.repos.ponce_git_mirror,
        }

        repo_path: Path = self.repos.repositories[repo]["path"]

        if ".git" in git_mirror[repo]:
            # Git mirror — local paths are handled natively by git.
            logger.debug(
                "Git mirror detected for '%s': %s. Removing existing folder and cloning.", repo, git_mirror[repo]
            )
            self.utils.remove_folder_if_exists(repo_path)
            syncing_command = shlex.split(self.git_clone) + [git_mirror[repo], str(repo_path)]
            logger.info("Executing git clone for '%s': %s", repo, syncing_command)
            self.multi_process.process(syncing_command)
            logger.info("Git clone completed for '%s'.", repo)
        elif self._is_local_mirror(mirror):
            # Local directory mirror — copy directly instead of using lftp.
            logger.info("Local directory mirror detected for '%s'. Copying directly.", repo)
            src = Path(mirror[len("file://"):] if mirror.startswith("file://") else mirror)
            if repo_path.exists():
                shutil.rmtree(repo_path)
            shutil.copytree(src, repo_path)
            logger.info("Copied local SlackBuild mirror '%s' to '%s'.", src, repo_path)
        else:
            # Remote LFTP mirror.
            logger.debug("LFTP mirror detected for '%s': %s. Removing specific files and mirroring.", repo, mirror)
            self.utils.remove_file_if_exists(repo_path, self.repos.repositories[repo]["slackbuilds_txt"])
            self.utils.remove_file_if_exists(repo_path, self.repos.repositories[repo]["changelog_txt"])
            self.utils.create_directory(repo_path)
            syncing_command = ["lftp", *shlex.split(self.lftp_mirror_options), mirror, str(repo_path)]
            logger.info("Executing lftp mirror for '%s': %s", repo, syncing_command)
            self.multi_process.process(syncing_command)
            logger.info("LFTP mirror completed for '%s'.", repo)

        # It checks if there is a SLACKBUILDS.TXT file, otherwise it's going to create one.
        slackbuilds_txt_path = Path(
            self.repos.repositories[repo]["path"], self.repos.repositories[repo]["slackbuilds_txt"]
        )
        if not slackbuilds_txt_path.is_file():
            logger.warning("SLACKBUILDS.TXT not found at '%s'. Attempting to generate it.", slackbuilds_txt_path)
            if ".git" in git_mirror[repo]:
                print()
            self.generate.slackbuild_file(
                self.repos.repositories[repo]["path"], self.repos.repositories[repo]["slackbuilds_txt"]
            )
            logger.info("SLACKBUILDS.TXT generated for '%s'.", repo)
        else:
            logger.debug("SLACKBUILDS.TXT found at '%s'. No generation needed.", slackbuilds_txt_path)

        logger.info("Installing SlackBuilds data for repository: %s", repo)
        self.data.install_sbo_data(repo)
        logger.info("SlackBuilds data installation completed for %s.", repo)

    @staticmethod
    def _parse_manifest(manifest_path: Path, package_types: list[str]) -> dict[str, list[str]]:
        """Parse a MANIFEST.bz2 file and return a mapping of package → file list.

        Args:
            manifest_path (Path): Path to the bzip2-compressed manifest file.
            package_types (list[str]): Recognised package file extensions (e.g. ['.txz', '.tgz']).

        Returns:
            dict: Mapping of package filename to list of files it contains.
        """
        filelist_index: dict[str, list[str]] = {}
        current_package = ""

        # Open the bzip2 file in read-text mode ('rt').
        with bz2.open(manifest_path, mode="rt", encoding="utf-8", errors="ignore") as f:
            for line in f:
                clean_line = line.strip()
                if not clean_line:
                    continue

                # Detect the start of a new package block.
                # Slackware manifests use '|| Package: /path/to/package.txz'.
                if "Package:" in clean_line and clean_line.startswith("||"):
                    parts = clean_line.split()
                    # Search for the package filename with a valid Slackware extension.
                    for part in parts:
                        if part.endswith(tuple(package_types)):
                            current_package = Path(part).name
                            filelist_index[current_package] = []
                            break
                    continue

                # Record files and symlinks associated with the current package.
                # Lines starting with '-' are regular files, 'l' are symbolic links.
                if current_package and (line.startswith("-") or line.startswith("l")):
                    parts = line.split()
                    if parts:
                        # The file path is always the last element in a long-format listing.
                        file_path = parts[-1]
                        # Filter out metadata and installation scripts (e.g., install/doinst.sh).
                        if not file_path.startswith(("./", "install/")):
                            filelist_index[current_package].append(file_path)

        return filelist_index

    @staticmethod
    def _write_filelist_index(repo_path: Path, filelist_index: dict[str, list[str]]) -> None:
        """Write the filelist index to a gzip-compressed JSON file.

        Performs an atomic replacement so the output file is never partially written.
        Also removes any legacy uncompressed filelist.json from older versions.

        Args:
            repo_path (Path): The local filesystem path to the repository.
            filelist_index (dict): Mapping of package filename to list of files.
        """
        output_gz: Path = repo_path / "filelist.json.gz"
        temp_gz: Path = repo_path / "filelist.json.gz.tmp"

        # Save in compact format to maximise compression efficiency.
        with gzip.open(temp_gz, "wt", encoding="utf-8") as jf:
            json.dump(filelist_index, jf, separators=(",", ":"))

        # Atomic replacement of the gzipped file.
        temp_gz.replace(output_gz)

        # Remove the old uncompressed JSON if it exists from previous versions.
        (repo_path / "filelist.json").unlink(missing_ok=True)

        logger.info("Generated compressed index %s", output_gz)

    def update_filelist_index(self, repo_path: Path, manifest_name: str) -> None:
        """Parse the MANIFEST.bz2 file and generate a gzip-compressed JSON index of package files.

        Args:
            repo_path (Path): The local filesystem path to the repository.
            manifest_name (str): The filename of the compressed manifest (e.g., MANIFEST.bz2).
        """
        manifest_path: Path = repo_path / manifest_name

        if not manifest_path.exists():
            logger.debug("Manifest not found at %s. Skipping index generation.", manifest_path)
            return

        try:
            filelist_index = self._parse_manifest(manifest_path, config_load.package_type)
        except Exception as e:  # pylint: disable=[W0718]
            logger.error("Error parsing manifest %s: %s", manifest_path, e)
            return

        if filelist_index:
            self._write_filelist_index(repo_path, filelist_index)
        else:
            logger.warning("No packages found in manifest: %s", manifest_path)
