#
# Bitcoin Safe
# Copyright (C) 2024 Andreas Griffin
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of version 3 of the GNU General Public License as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see https://www.gnu.org/licenses/gpl-3.0.html
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

import glob
import hashlib
import logging
import os
import platform
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Union

import pgpy  # Python-native OpenPGP library
import requests

gnupg = None
logger = logging.getLogger(__name__)


@dataclass
class SimpleGPGKey:
    key: str
    repository: str  # org/repo_name
    prefix: str
    manifest_ending: Optional[str] = None

    @staticmethod
    def extract_prefix_and_version(filename: str) -> tuple[Optional[str], Optional[str]]:
        import re

        if filename.endswith(".deb"):
            match = re.search(r"(.+)_(.+?)(?:-.+)?_.*\.deb", filename)
            if match:
                return (match.group(1), match.group(2))

        # try with - separator
        match = re.search(r"(.*?)-([\d\.]+[a-zA-Z0-9]*)", filename)
        if match:
            return (match.group(1), match.group(2))

        # try with _ separator
        match = re.search(r"(.*?)_([\d\.]+[a-zA-Z0-9]*)", filename)
        if match:
            return (match.group(1), match.group(2))
        return (None, None)

    def get_tag_if_mine(self, filename: str) -> Optional[str]:
        prefix, version = self.extract_prefix_and_version(filename)
        if prefix and prefix.lower() == self.prefix.lower():
            return version
        return None


@dataclass
class Asset:
    tag: str
    url: str
    name: str


class GitHubAssetDownloader:
    def __init__(self, repository: str) -> None:
        self.repository = repository
        logger.debug(f"initialized {self}")

    def _get_assets(self, api_url) -> List[Asset]:
        try:
            logger.debug(f"Get assets from {api_url}")
            response = requests.get(api_url, timeout=2)
            response.raise_for_status()
            assets = response.json().get("assets", [])

            return [
                Asset(
                    tag=response.json().get("tag_name", ""),
                    url=asset["browser_download_url"],
                    name=asset["name"],
                )
                for asset in assets
            ]
        except requests.RequestException as e:
            logger.error(f"Failed to download: {api_url}")
            return []

    def get_assets_by_tag(self, tag: str) -> List[Asset]:
        return self._get_assets(f"https://api.github.com/repos/{self.repository}/releases/tags/{tag}")

    def get_assets_latest(self) -> List[Asset]:
        return self._get_assets(f"https://api.github.com/repos/{self.repository}/releases/latest")


class SignatureVerifyer:
    def __init__(
        self,
        list_of_known_keys: Optional[List[SimpleGPGKey]],
    ) -> None:
        self.list_of_known_keys = list_of_known_keys if list_of_known_keys else []
        self.public_keys: Dict[str, pgpy.PGPKey] = {}
        self.import_known_keys()

    def import_public_key_file(self, path: Path) -> pgpy.PGPKey:
        with open(str(path), "r") as file:
            return self.import_public_key_block(file.read())

    def import_public_key_block(self, public_key_block: str) -> pgpy.PGPKey:
        """
        Import a public key block and return its fingerprint.

        :param public_key_block: The public key block in ASCII armor format.
        :return: The fingerprint of the imported public key.
        """

        result = pgpy.PGPKey.from_blob(public_key_block)
        public_key = None
        if isinstance(result, pgpy.PGPKey):
            public_key = result
        elif isinstance(result, tuple):
            public_key, _ = result

        if isinstance(public_key, pgpy.PGPKey):
            fingerprint = str(public_key.fingerprint)
            self.public_keys[fingerprint] = public_key
            logger.info(f"Public key imported with fingerprint: {public_key.fingerprint}")
            return public_key

        raise Exception(f"Could not process result f{result}")

    def import_known_keys(self) -> None:
        for key in self.list_of_known_keys:
            self.import_public_key_block(key.key)

    @staticmethod
    def verify_manifest_hashes(manifest_file: Path) -> bool:
        # Check if the manifest file exists
        if not manifest_file.exists():
            print("Manifest file does not exist.")
            return False

        file_found = False  # To track if at least one file is found

        with manifest_file.open("r") as file:
            for line in file:
                # Extract expected hash and file name from each line
                parts = line.strip().split(" *")
                if len(parts) != 2:
                    print("Invalid line format in manifest:", line)
                    continue  # Skip invalid lines

                expected_hash, file_name = parts
                file_path = manifest_file.parent / file_name

                # Check if the file exists
                if not file_path.exists():
                    logger.debug(f"File {file_name} listed in the manifest does not exist.")
                    continue  # It's okay if some files don't exist, but at least one must be found

                file_found = True  # Mark that we've found at least one file

                # Compute the file's SHA-256 hash
                file_hash = hashlib.sha256()
                with open(file_path, "rb") as f:
                    for chunk in iter(lambda: f.read(4096), b""):
                        file_hash.update(chunk)

                # Compare the computed hash with the expected hash
                if file_hash.hexdigest() != expected_hash:
                    logger.error(f"Hash mismatch for {file_name}.")
                    return False

        if not file_found:
            print("None of the files listed in the manifest are present.")
            return False  # Return False if none of the files are found

        return True

    def get_valid_manifest(self, signature_file: Path) -> Optional[Path]:
        manifest = signature_file.parent / signature_file.name.removesuffix(".asc")
        return manifest if self.verify_manifest_hashes(manifest) else None

    def verify_signature(self, binary_filename: Path, expected_public_key: SimpleGPGKey) -> bool:
        signature_file = self.get_signature_file(binary_filename)
        if not signature_file:
            signature_file = self.get_signature_from_web(binary_filename)
            if not signature_file:
                logger.error("Could not download signature file")
                return False

        key = self.get_key(binary_filename)
        public_key = self.import_public_key_block(expected_public_key.key)
        if not public_key:
            logger.error("Expected public key not found.")
            return False

        binary_file_to_check = binary_filename
        if key and key.manifest_ending:
            manifest_file = self.get_valid_manifest(signature_file)
            if not manifest_file:
                return False
            binary_file_to_check = manifest_file

        verification_result = self._verify_file(
            public_key=public_key, binary_file=binary_file_to_check, signature_file=signature_file
        )

        return verification_result

    def _verify_file(
        self, public_key: pgpy.PGPKey, binary_file: Union[Path, str], signature_file: Union[Path, str]
    ) -> bool:
        try:
            with open(str(signature_file), "rb") as sig_file:
                signature = pgpy.PGPSignature.from_blob(sig_file.read())

            with open(str(binary_file), "rb") as bin_file:
                message_data = bin_file.read()

            pgpmessage = pgpy.PGPMessage.new(message_data, file=True)

            # Verify the signature
            verify_result = public_key.verify(pgpmessage.message, signature)
            if not verify_result:
                return False
            good_signatures = list(verify_result.good_signatures)
            bad_signatures = list(verify_result.bad_signatures)
            # 1 single bad signature creates a False result
            return bool(good_signatures) and not bool(bad_signatures)
        except Exception as e:
            logger.error(f"Verification failed: {e}")
            return False

    @staticmethod
    def _get_asset_url(assets: List[Asset], ending: str) -> Optional[str]:
        for asset in assets:
            if asset.name.endswith(ending):
                return asset.url
        return None

    @staticmethod
    def _download_file(download_url: str, filename: Path) -> Path:
        sig_response = requests.get(download_url, timeout=2)
        sig_response.raise_for_status()
        with open(filename, "wb") as f:
            f.write(sig_response.content)
        return filename

    @staticmethod
    def _download_asset_file(
        assets: List[Asset], target_directory: Path, asset_ending: str
    ) -> Optional[Path]:
        if url := SignatureVerifyer._get_asset_url(assets, asset_ending):
            url_filename = Path(url).name
            filename = target_directory / url_filename
            SignatureVerifyer._download_file(url, filename)
            return filename
        return None

    def get_signature_file(self, binary_filename: Path) -> Optional[Path]:
        key = self.get_key(binary_filename)
        if key and key.manifest_ending:
            manifest_files = glob.glob(str(binary_filename.parent / f"*{key.manifest_ending}"))
            sig_files = glob.glob(str(binary_filename.parent / f"*{key.manifest_ending}.asc"))

            if not manifest_files or not sig_files:
                return None
            return Path(sig_files[0])
        else:
            return binary_filename.parent / (binary_filename.name + ".asc")

    def is_signature_file_available(self, binary_filename: Path) -> bool:
        sig_file = self.get_signature_file(binary_filename)
        return sig_file.exists() if sig_file else False

    def get_key(self, binary_filename: Path) -> Optional[SimpleGPGKey]:
        for key in self.list_of_known_keys:
            tag = key.get_tag_if_mine(binary_filename.name)
            if tag:
                return key
        return None

    def get_signature_from_web(self, binary_filename: Path) -> Optional[Path]:
        sig_filename = None
        key = self.get_key(binary_filename)
        if not key:
            return None
        tag = key.get_tag_if_mine(binary_filename.name)
        if not tag:
            return None

        assets = GitHubAssetDownloader(repository=key.repository).get_assets_by_tag(tag)
        if assets:
            if key.manifest_ending:
                self._download_asset_file(
                    assets, target_directory=binary_filename.parent, asset_ending=key.manifest_ending
                )
                sig_filename = self._download_asset_file(
                    assets,
                    target_directory=binary_filename.parent,
                    asset_ending=f"{key.manifest_ending}.asc",
                )
            else:
                sig_filename = self._download_asset_file(
                    assets,
                    target_directory=binary_filename.parent,
                    asset_ending=f"{binary_filename.name}.asc",
                )
        return sig_filename

    def get_key_meta_info(self, key: SimpleGPGKey) -> Dict:
        """
        Retrieve meta-information about a key given its fingerprint.

        :param key: The GPGKey object containing the key data.
        :return: A dictionary containing the key's meta-information.
        """
        public_key = self.import_public_key_block(key.key)

        key_info = {
            "fingerprint": public_key.fingerprint,
            "userids": [uid.name for uid in public_key.userids],
            "created": public_key.created,
            "algorithm": public_key.key_algorithm.name,
        }
        return key_info


class GPGSignatureVerifyer:
    def __init__(
        self,
        list_of_known_keys: Optional[List[SimpleGPGKey]],
    ) -> None:
        self.list_of_known_keys = list_of_known_keys if list_of_known_keys else []
        self._gpg: Any = None
        self.import_known_keys()

    @staticmethod
    def _get_gpg_path() -> Optional[str]:
        command = "gpg"
        gpg_path = shutil.which(command)
        if gpg_path:
            try:
                subprocess.run(
                    [command, "--version"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
                )
                return command
            except subprocess.CalledProcessError:
                return None
        elif platform.system() == "Windows":
            import winreg

            try:
                # Open the registry key
                with winreg.OpenKey(  # type: ignore
                    winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\GnuPG", 0, winreg.KEY_READ  # type: ignore
                ) as key:
                    # Read the value of the Install Directory
                    install_dir, _ = winreg.QueryValueEx(key, "Install Directory")  # type: ignore
                    gpg_path = os.path.join(install_dir, "bin", "gpg.exe")
                    if os.path.isfile(gpg_path):
                        return gpg_path
            except FileNotFoundError:
                pass
            except OSError as e:
                print(f"Error accessing registry: {e}")
            return None
        return None

    @classmethod
    def is_gnupg_installed(cls) -> bool:
        return bool(cls._get_gpg_path())

    @property
    def gpg(self) -> Any:
        if self._gpg:
            return self._gpg

        if self.is_gnupg_installed():
            global gnupg
            import gnupg as _gnupg

            gnupg = _gnupg

            gpg_path = self._get_gpg_path()
            if not gpg_path:
                raise EnvironmentError("GnuPG path could not be determined.")
            self._gpg = gnupg.GPG(gpgbinary=gpg_path)
            return self._gpg
        else:
            raise EnvironmentError("GnuPG is not installed on this system.")

    def import_known_keys(self) -> None:
        for key in self.list_of_known_keys:
            self.gpg.import_keys(key.key)

    def import_public_key_block(self, public_key_block: str) -> str:
        """
        Import a public key block into the GnuPG keyring and return its fingerprint.

        :param public_key_block: The public key block in ASCII armor format.
        :return: The fingerprint of the imported public key.
        """
        import_result = self.gpg.import_keys(public_key_block)

        if not import_result.results:
            raise ValueError("Failed to import public key.")

        # The 'fingerprint' attribute is available in the import result.
        # For multiple keys, this simple implementation assumes you're interested in the first.
        fingerprint = import_result.results[0]["fingerprint"]
        logger.info(f"Public key imported with fingerprint: {fingerprint}")
        return fingerprint


class SignatureSigner(GPGSignatureVerifyer):
    def __init__(
        self,
        version: str,
        app_name: str,
        list_of_known_keys: Optional[List[SimpleGPGKey]],
        file_dir: str = "dist/",
    ) -> None:
        super().__init__(list_of_known_keys=list_of_known_keys)

        self.file_dir = file_dir
        self.version = version
        self.app_name = app_name

    def get_files_to_sign(self) -> List[Path]:
        return [
            f
            for f in Path(self.file_dir).iterdir()
            if f.is_file() and (not f.name.endswith(".asc")) and (not f.name.startswith("."))
        ]

    def sign_files(self, key: SimpleGPGKey) -> List[Path]:
        my_fingerprint = self.import_public_key_block(key.key)
        files = self.get_files_to_sign()
        for file_path in files:

            self.gpg.sign_file(
                open(file_path, "rb"), keyid=my_fingerprint, detach=True, output=str(file_path) + ".asc"
            )
            logger.info(f"File signed: {file_path.name}.asc")
        return files


@dataclass
class KnownGPGKeys:

    andreasgriffin = SimpleGPGKey(
        key="""
-----BEGIN PGP PUBLIC KEY BLOCK-----

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
26s3OWCbYId2XIroAoNjRiIp
=aRyX
-----END PGP PUBLIC KEY BLOCK-----               
                   """,
        repository="andreasgriffin/bitcoin-safe",
        prefix="bitcoin-safe",
    )
    craigraw = SimpleGPGKey(
        repository="sparrowwallet/sparrow",
        prefix="sparrow",
        manifest_ending="manifest.txt",
        key="""
-----BEGIN PGP PUBLIC KEY BLOCK-----
Comment: GPGTools - http://gpgtools.org


EmR2yA==
=t5JY
-----END PGP PUBLIC KEY BLOCK-----

               """,
    )

    @classmethod
    def all(cls) -> List[SimpleGPGKey]:
        return [v for v in cls.__dict__.values() if isinstance(v, SimpleGPGKey)]
