import os
import requests
import traceback
import threading
import tempfile
import shutil
import zipfile
from pathlib import Path
from shared.utils import Logger
from shared.contracts import AfterUpdateCallback, AfterUpdateFailCallback
# =============== Update Service ===============
class UpdateService:
    def __init__(self):
        # Logger
        self.logger = Logger.create_logger(logger_name = "Update Service", log_file_name = "update_app")
        # Base Path
        self.basePath: Path = Path(__file__).parent.parent.parent.parent.parent
        
    # ========== Update Functionality ==========
    def update(self, kit_url: str, after_update_callback: AfterUpdateCallback, after_update_fail_callback: AfterUpdateFailCallback) -> None:
        try:
            threading.Thread(
                target = self.update_worker,
                args=[kit_url, after_update_callback, after_update_fail_callback], 
                daemon = True).start()
        except:
            traceBack = traceback.format_exc()
            self.logger.error(
                f"[{self.__class__.__name__} - {self.update.__name__}] "
                f"Failed to update the validation kit. \n{traceBack}")
    
    def update_worker(self, kit_url: str, after_update_callback: AfterUpdateCallback, after_update_fail_callback: AfterUpdateFailCallback) -> None:
        try:
            # ---------------------
            # Folders to ignore
            # ---------------------
            ignore_folders = [
                "_internal",
                "resources/shared/cache",
                "resources/updater",
                "resources/validator/src/packages/development",
                "resources/validator/src/packages/public"]

            # ---------------------
            # Files to ignore
            # ---------------------
            ignore_files = [
                "resources/shared/keys/tokens.json",
                "Validator.exe"]

            temp_dir = tempfile.TemporaryDirectory()
            temp_path = temp_dir.name
            zip_path = os.path.join(temp_path, "update.zip")

            # ---------------------
            # Download ZIP
            # ---------------------
            response = requests.get(kit_url, stream=True)
            response.raise_for_status()
            with open(zip_path, "wb") as f:
                for chunk in response.iter_content(chunk_size=8192):
                    f.write(chunk)

            # ---------------------
            # Extract ZIP safely
            # ---------------------
            def safe_extract(zip_ref, extract_to):
                extract_to_abs = os.path.abspath(extract_to) + os.sep
                for member in zip_ref.namelist():
                    member_path = os.path.abspath(os.path.join(extract_to, member))
                    if not member_path.startswith(extract_to_abs):
                        raise Exception("Zip contains unsafe paths!")
                zip_ref.extractall(extract_to)

            with zipfile.ZipFile(zip_path, "r") as zip_ref:
                safe_extract(zip_ref, temp_path)

            # ---------------------
            # Detect real root folder
            # ---------------------
            items = [item for item in os.listdir(temp_path) if item != "update.zip"]
            if len(items) == 1 and os.path.isdir(os.path.join(temp_path, items[0])):
                update_root = os.path.join(temp_path, items[0])
            else:
                update_root = temp_path

            # ---------------------
            # Delete cache folders and files from ZIP
            # ---------------------
            for root, dirs, files in os.walk(update_root, topdown=False):
                for d in dirs:
                    dir_path = os.path.join(root, d)
                    if d == "__pycache__":
                        shutil.rmtree(dir_path, ignore_errors=True)
                for f in files:
                    if f.endswith((".pyc", ".pyo")):
                        try:
                            os.remove(os.path.join(root, f))
                        except Exception as e:
                            self.logger.warning(f"Failed to delete cache file: {e}")

            # ---------------------
            # Convert ignore_folders to absolute paths (both ZIP and dest)
            # ---------------------
            ignore_folders_in_zip  = [os.path.abspath(os.path.join(update_root,    f)) for f in ignore_folders]
            ignore_folders_on_disk = [os.path.abspath(os.path.join(self.basePath,  f)) for f in ignore_folders]

            ignore_files_on_disk = [os.path.abspath(os.path.join(self.basePath, f)) for f in ignore_files]
            ignore_files_in_zip  = [os.path.abspath(os.path.join(update_root,   f)) for f in ignore_files]
            # ---------------------
            # Collect incoming files (ZIP → dest mapping)
            # ---------------------
            file_map = {}  # relative_path -> full_path_in_zip
            dir_set  = set()

            for root, dirs, files in os.walk(update_root):
                dirs[:] = [
                    d for d in dirs
                    if os.path.abspath(os.path.join(root, d)) not in ignore_folders_in_zip
                    and d != "__pycache__"
                ]
                for file in files:
                    if file.endswith((".pyc", ".pyo")):
                        continue
                    full_path = os.path.join(root, file)
                    if os.path.abspath(full_path) in ignore_files_in_zip:
                        continue
                    relative_path = os.path.relpath(full_path, update_root)
                    file_map[relative_path] = full_path

                relative_dir = os.path.relpath(root, update_root)
                dir_set.add(relative_dir)

            # ---------------------
            # Backup existing files that will be replaced or deleted
            # ---------------------
            backup_dir     = tempfile.mkdtemp(prefix="update_backup_")
            replaced_files = []  # files overwritten
            deleted_files  = []  # files removed (not in ZIP)
            deleted_dirs   = []  # dirs removed (not in ZIP)

            def is_ignored_on_disk(path: str) -> bool:
                abs_path = os.path.abspath(path)
                return any(abs_path == f or abs_path.startswith(f + os.sep) for f in ignore_folders_on_disk)

            def is_ancestor_of_ignored(path: str) -> bool:
                abs_path = os.path.abspath(path)
                return any(f.startswith(abs_path + os.sep) for f in ignore_folders_on_disk)

            try:
                # --- Backup & mark files to delete ---
                for root, dirs, files in os.walk(self.basePath):
                    dirs[:] = [
                        d for d in dirs
                        if not is_ignored_on_disk(os.path.join(root, d))
                        and d != "__pycache__"
                    ]
                    for file in files:
                        if file.endswith((".pyc", ".pyo")):
                            continue
                        full_dest     = os.path.join(root, file)
                        relative_path = os.path.relpath(full_dest, self.basePath)
                        backup_path   = os.path.join(backup_dir, relative_path)
                        os.makedirs(os.path.dirname(backup_path), exist_ok=True)
                        shutil.copy2(full_dest, backup_path)

                        if relative_path not in file_map:
                            if os.path.abspath(full_dest) not in ignore_files_on_disk:
                                deleted_files.append(relative_path)
                        else:
                            replaced_files.append(relative_path)

                # --- Mark empty/extra dirs to delete (deepest first) ---
                for root, dirs, files in os.walk(self.basePath, topdown=False):
                    if is_ignored_on_disk(root):
                        continue
                    relative_dir = os.path.relpath(root, self.basePath)
                    if relative_dir != "." and relative_dir not in dir_set and not is_ancestor_of_ignored(root):
                        deleted_dirs.append(relative_dir)

                # ---------------------
                # Delete extra files
                # ---------------------
                for relative_path in deleted_files:
                    dest_path = os.path.join(self.basePath, relative_path)
                    os.remove(dest_path)

                # ---------------------
                # Delete extra dirs (deepest first, skip if not empty due to ignored content)
                # ---------------------
                for relative_dir in sorted(deleted_dirs, key=lambda p: -p.count(os.sep)):
                    dest_dir = os.path.join(self.basePath, relative_dir)
                    if os.path.isdir(dest_dir):
                        shutil.rmtree(dest_dir, ignore_errors=True)

                # ---------------------
                # Create new dirs
                # ---------------------
                for relative_dir in dir_set:
                    dest_dir = os.path.join(self.basePath, relative_dir)
                    os.makedirs(dest_dir, exist_ok=True)

                # ---------------------
                # Replace / add files
                # ---------------------
                for relative_path, full_path in file_map.items():
                    dest_path = os.path.join(self.basePath, relative_path)
                    os.makedirs(os.path.dirname(dest_path), exist_ok=True)
                    if os.path.exists(dest_path):
                        os.replace(full_path, dest_path)
                    else:
                        shutil.move(full_path, dest_path)

                # ---------------------
                # Success → delete backup
                # ---------------------
                shutil.rmtree(backup_dir)

            except Exception as e:
                # ---------------------
                # Rollback: restore backed-up files
                # ---------------------
                for relative_path in replaced_files + deleted_files:
                    backup_path = os.path.join(backup_dir, relative_path)
                    dest_path   = os.path.join(self.basePath, relative_path)
                    if os.path.exists(backup_path):
                        os.makedirs(os.path.dirname(dest_path), exist_ok=True)
                        os.replace(backup_path, dest_path)
                shutil.rmtree(backup_dir, ignore_errors=True)
                raise e

            finally:
                temp_dir.cleanup()
                updateZip = self.basePath.joinpath("update.zip")
                if updateZip.exists():
                    os.unlink(updateZip)
            # ---------------------
            # Update callback
            # ---------------------
            after_update_callback()

        except:
            traceBack = traceback.format_exc()
            self.logger.error(
                f"[{self.__class__.__name__} - {self.update_worker.__name__}] "
                f"Failed to update the validation kit. \n{traceBack}"
            )
            after_update_fail_callback()
    # ==========================================
# ==============================================