#!python
"""Game updater and launcher with progress bar using GitPython and Tkinter."""

import sys
import importlib.util
import sysconfig
import urllib.request
import ssl
from pathlib import Path
import logging

import threading
import queue
import time

import tkinter as tk
from tkinter import ttk
from tkinter import filedialog

from subprocess import Popen

from git import RemoteProgress, Repo
import tomllib
import hashlib
import tarfile
import shutil
import os

from platformdirs import user_data_dir, user_log_dir

VERSION = "0.2.1"
MANIFESTURL = "https://gitlab.com/api/v4/projects/69700443/packages/generic/Launcher/latest/launcher_Linux.manifest"
## set somewhere: git config pull.rebase true


# Run modes (same rules as Opengate/env.py so launcher and game agree):
#   insource -- cloned repo; logs, config and assets live in the checkout.
#   pip      -- ``pip install opengate-game``; everything lives in the
#               per-user OS directories, the launcher only updates the assets.
#   binary   -- setup.exe / frozen bundle; per-user OS directories as well.
INSOURCE = "insource"
PIP = "pip"
BINARY = "binary"


def detectRunMode():
    """Return ``(mode, root)``; ``root`` is the checkout dir for insource.

    The mode follows from *where the Opengate package is installed* (found
    without importing it, which would drag in Ogre) and not from the current
    working directory, which is arbitrary when started from a shortcut or a
    shell. A package with a ``.git``/``pyproject.toml`` next to it is a source
    checkout (this includes ``pip install -e``); one in site-packages is a pip
    install; no package at all means a standalone/frozen binary install.
    """
    if getattr(sys, "frozen", False):
        return BINARY, None

    try:
        spec = importlib.util.find_spec("Opengate")
    except (ImportError, ValueError):
        spec = None
    if spec is None or not spec.origin:
        return BINARY, None

    root = Path(spec.origin).resolve().parent.parent
    if (root / ".git").exists() or (root / "pyproject.toml").exists():
        return INSOURCE, root
    return PIP, None


runMode, sourceRoot = detectRunMode()
insource = runMode == INSOURCE
running_mode = runMode
appPath = str(sourceRoot) if insource else str(Path.cwd())

# Insource keeps the log next to the code; pip/binary installs have no
# writable project root, so they get the OS-standard per-user log directory.
log_dir = Path(appPath) if insource else Path(user_log_dir("opengate"))
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / ".launcher.log"

# Configure logging to both file and console
logging.basicConfig(
    level=logging.INFO,
    format="[%(asctime)s] %(levelname)s: %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
    handlers=[logging.FileHandler(log_file), logging.StreamHandler(sys.stdout)],
)

logger = logging.getLogger(__name__)
logger.info(f"=== OpenGate Launcher v{VERSION} started ===")
logger.info(f"Log file: {log_file}")
logger.info(f"App path: {appPath} (mode: {running_mode})")


class ProgressFrame(tk.Frame):
    """A frame containing a label, progress bar, and info label."""

    def __init__(self, parent, title):
        super().__init__(parent)

        self.titleLabel = tk.Label(self, text=title, font=("Arial", 9, "bold"))
        self.titleLabel.pack(pady=(10, 5))

        self.progressBar = ttk.Progressbar(self, length=550, mode="determinate")
        self.progressBar.pack(pady=5)

        self.infoLabel = tk.Label(self, text="Waiting...", font=("Arial", 9))
        self.infoLabel.pack(pady=5)

        # Separator
        ttk.Separator(self, orient="horizontal").pack(fill="x", pady=10)


class MainWindow:
    """Main window containing all progress bars."""

    def __init__(self):
        self.root = tk.Tk()
        self.root.title(f"OpenGate Launcher {VERSION}")
        self.root.geometry("600x600")

        # Main container with scrollbar
        self.canvas = tk.Canvas(self.root)
        self.scrollbar = ttk.Scrollbar(
            self.root, orient="vertical", command=self.canvas.yview
        )
        self.scrollableFrame = tk.Frame(self.canvas)

        self.scrollableFrame.bind(
            "<Configure>",
            lambda e: self.canvas.configure(
                scrollregion=self.canvas.bbox("all")
            ),
        )

        self.canvas.create_window(
            (0, 0), window=self.scrollableFrame, anchor="nw"
        )
        self.canvas.configure(yscrollcommand=self.scrollbar.set)

        self.canvas.pack(side="left", fill="both", expand=True)
        self.scrollbar.pack(side="right", fill="y")

        self.progressFrames = {}

    def addProgressFrame(self, key, title):
        """Add a new progress frame to the window."""
        frame = ProgressFrame(self.scrollableFrame, title)
        frame.pack(fill="x", padx=10)
        self.progressFrames[key] = frame
        return frame

    def progressFrame(self, key):
        """Get existing progress frame by key."""
        return self.progressFrames.get(key)

    def update(self):
        """Update the window."""
        self.root.update_idletasks()
        self.root.update()

    def show(self):
        """Show the window."""
        self.root.deiconify()

    def hide(self):
        """Hide the window."""
        self.root.withdraw()

    def destroy(self):
        """Destroy the window."""
        self.root.destroy()


class CloneProgress(RemoteProgress):
    """Class to handle git clone/pull progress updates."""

    OP_CODES = [
        "BEGIN",
        "CHECKING_OUT",
        "COMPRESSING",
        "COUNTING",
        "END",
        "FINDING_SOURCES",
        "RECEIVING",
        "RESOLVING",
        "WRITING",
    ]
    OP_CODE_MAP = {
        getattr(RemoteProgress, _op_code): _op_code for _op_code in OP_CODES
    }

    def __init__(self, updateQueue):
        super().__init__()
        self.updateQueue = updateQueue
        self.startTime = time.time()

    @classmethod
    def getCurrOp(cls, opCode: int) -> str:
        """Get OP name from OP code."""
        # Remove BEGIN- and END-flag and get op name
        opCodeMasked = opCode & cls.OP_MASK
        return cls.OP_CODE_MAP.get(opCodeMasked, "?").title()

    def update(self, opCode, curCount, maxCount=None, message=""):
        """Update progress information."""
        if maxCount:
            currentTime = time.time()
            self.updateQueue.put(
                (
                    self.getCurrOp(opCode),
                    maxCount,
                    curCount,
                    message,
                    currentTime,
                    self.startTime,
                )
            )


def findPipOg():
    """Locate the ``og`` script that ``pip install opengate-game`` installed.

    Prefer the scripts directory of the running interpreter (the venv/env the
    launcher itself was installed into) over whatever happens to be first on
    PATH, which may belong to a different or stale installation.
    """
    scriptsDir = Path(sysconfig.get_path("scripts"))
    for name in ("og.exe", "og"):
        candidate = scriptsDir / name
        if candidate.exists():
            return str(candidate)
    return shutil.which("og")


def resolveAssetsPath(insource):
    """Decide where the assets repo belongs for this run.

    Insource runs (cloned repository, ``og`` script present next to the
    launcher) keep the assets next to the code, as before. Binary runs (pip
    install or a distributed package) have no project root to anchor to, so
    they use the OS-standard per-user data directory instead -- kept separate
    from the installed code/binaries on purpose, so assets and code can be
    updated independently.
    """
    if insource:
        return Path(appPath) / "assets"
    return Path(user_data_dir("opengate")) / "assets"


def processGitOperations(assetsPath, updateQueue, doneQueue):
    """Run git operations in a background thread."""
    try:
        logger.info(f"Assets path: {assetsPath}")

        if not assetsPath.exists():
            logger.info("Cloning assets repository...")
            Repo.clone_from(
                url="https://gitlab.com/Turb/opengate-assets.git",
                to_path=assetsPath,
                progress=CloneProgress(updateQueue),
                depth=1,
            )
        else:
            logger.info("Assets repository already exists.")
            repo = Repo(assetsPath)
            # Compare against the remote tip before fetching -- this is a
            # cheap ls-remote round trip that avoids the heavier fetch
            # negotiation/pack transfer entirely on the common case where
            # assets haven't changed since the last launch.
            remoteSha = repo.git.ls_remote("origin", "HEAD").split()[0]
            if repo.head.commit.hexsha == remoteSha:
                logger.info("Assets repository already up to date.")
                doneQueue.put(("success", None))
                return

            logger.info("Updating assets repository...")
            # Shallow (--depth=1) histories are unrelated to each other once
            # upstream has moved on, so a merge-based pull fails with
            # "refusing to merge unrelated histories". Fetch the latest
            # shallow commit and hard-reset onto it instead -- assets is a
            # managed mirror, never locally edited, so there is nothing to
            # merge or preserve.
            repo.remotes.origin.fetch(
                progress=CloneProgress(updateQueue), depth=1
            )
            repo.git.reset("--hard", "FETCH_HEAD")
            # Old shallow commits' now-unreachable blobs/trees would
            # otherwise accumulate on disk across updates.
            repo.git.gc("--prune=now")

        doneQueue.put(("success", None))
    except Exception as e:
        logger.error(f"Git operations failed: {e}")
        doneQueue.put(("error", str(e)))


def download(manifest, downloadsPath, key, mainWindow):
    """Download file with progress bar."""
    url = manifest["url"]
    sha256 = manifest["sha-256"]
    filename = url.split("/")[-1]

    downloadPath = downloadsPath / filename
    progressFrame = mainWindow.progressFrames[key]

    logger.info(f"Downloading ... {downloadPath}")
    if downloadPath.exists():
        # Calculate SHA-256 of existing file
        sha256Hash = hashlib.sha256()
        with downloadPath.open("rb") as f:
            for byteBlock in iter(lambda: f.read(4096), b""):
                sha256Hash.update(byteBlock)

        if sha256Hash.hexdigest() == sha256:
            progressFrame.infoLabel.config(
                text="Already exists with correct SHA-256"
            )
            logger.info(
                f"File {downloadPath} already exists with correct SHA-256"
            )
            return
        else:
            progressFrame.infoLabel.config(
                text="SHA-256 mismatch, re-downloading..."
            )
            logger.warning(f"{sha256Hash.hexdigest()} != {sha256}")
            logger.info(
                f"File {downloadPath} exists but SHA-256 mismatch, downloading..."
            )

    # exit()
    progressFrame.infoLabel.config(text="Starting download...")
    startTime = time.time()

    def reporthook(blockNum, blockSize, totalSize):
        """Update progress bar during download."""
        downloaded = blockNum * blockSize

        if totalSize > 0:
            progressFrame.progressBar["maximum"] = totalSize
            progressFrame.progressBar["value"] = min(downloaded, totalSize)

            # Calculate statistics
            elapsedTime = time.time() - startTime

            if elapsedTime > 0:
                speedBytes = downloaded / elapsedTime
                speedMb = speedBytes / (1024 * 1024)

                # Calculate ETA
                remaining = totalSize - downloaded
                if speedBytes > 0:
                    etaSeconds = remaining / speedBytes
                    etaMinutes = int(etaSeconds // 60)
                    etaSecondsRem = int(etaSeconds % 60)

                    if etaMinutes > 0:
                        etaStr = f"eta: {etaMinutes}m {etaSecondsRem}s"
                    else:
                        etaStr = f"eta: {etaSecondsRem}s"
                else:
                    etaStr = "eta: calculating..."

                # Format size
                downloadedMb = downloaded / (1024 * 1024)
                totalMb = totalSize / (1024 * 1024)

                infoText = f"{downloadedMb:.1f}/{totalMb:.1f} MB ({speedMb:.1f} MB/s, {etaStr})"
                progressFrame.infoLabel.config(text=infoText)

        mainWindow.update()

    try:
        logger.info(f"Downloading file {downloadPath}...")
        try:
            # Create SSL context for download
            ssl_context = ssl.create_default_context()
            urllib.request.urlretrieve(url, downloadPath, reporthook)  # noqa: S310 -- URL comes from our own signed manifest

        except urllib.error.URLError:
            # Fallback: disable certificate verification if it fails
            logger.warning(
                "SSL certificate verification failed, retrying without verification..."
            )
            ssl_context = ssl._create_unverified_context()  # noqa: S323 -- fallback for hosts with broken CA bundles
            opener = urllib.request.build_opener(
                urllib.request.HTTPSHandler(context=ssl_context)
            )
            urllib.request.install_opener(opener)
            urllib.request.urlretrieve(url, downloadPath, reporthook)  # noqa: S310 -- URL comes from our own signed manifest

        except Exception as e:
            logger.error(f"Download error: {type(e)} {e}")
            raise

        # Verify SHA-256
        progressFrame.infoLabel.config(text="Verifying file integrity...")
        mainWindow.update()

        sha256Hash = hashlib.sha256()
        with downloadPath.open("rb") as f:
            for byteBlock in iter(lambda: f.read(4096), b""):
                sha256Hash.update(byteBlock)

        if sha256Hash.hexdigest() == sha256:
            progressFrame.infoLabel.config(
                text="Download complete and verified!"
            )
            logger.info(
                f"File {downloadPath} downloaded and verified successfully"
            )

            extractPath = downloadsPath.parent

            if filename.endswith(".tar.gz"):
                progressFrame.infoLabel.config(text="Unpacking archive...")
                mainWindow.update()

                logger.info(f"Extracting {filename} to {extractPath}...")

                with tarfile.open(downloadPath, "r:gz") as tarRef:
                    # SHA-256 verified above and archive originates from our own manifest
                    tarRef.extractall(path=extractPath, filter="fully_trusted")  # noqa: S202

                progressFrame.infoLabel.config(
                    text="Archive unpacked successfully!"
                )
                logger.info(f"Archive extracted to {extractPath}")

            # Handle files without suffix - copy to parent directory
            elif "." not in filename:
                progressFrame.infoLabel.config(text="Copying executable...")
                mainWindow.update()

                copyPath = extractPath / filename
                logger.info(f"Copying {filename} to {copyPath}...")

                shutil.copy2(downloadPath, copyPath)
                copyPath.chmod(0o755)  # Make executable

                progressFrame.infoLabel.config(
                    text="Executable copied successfully!"
                )
                logger.info(f"File copied to {copyPath} and made executable")

        else:
            progressFrame.infoLabel.config(text="SHA-256 verification failed!")
            logger.error(f"SHA-256 mismatch for {filename}")

        mainWindow.update()
        time.sleep(0.5)

    except Exception as e:
        progressFrame.infoLabel.config(text=f"Download failed: {str(e)}")
        logger.error(f"Download error: {e}")
        mainWindow.update()
        time.sleep(1)


def updateAssets(assetsPath, mainWindow, doneCB=None):
    """Update assets with progress in main window."""
    progressFrame = mainWindow.progressFrame("assets")

    updateQueue = queue.Queue()
    doneQueue = queue.Queue()

    # Start git operations in background thread
    gitThread = threading.Thread(
        target=processGitOperations,
        args=(assetsPath, updateQueue, doneQueue),
        daemon=True,
    )
    gitThread.start()

    def checkUpdates():
        """Check for updates from the git thread and update GUI."""
        # Process all queued updates
        while not updateQueue.empty():
            try:
                (
                    msgType,
                    maxCount,
                    curCount,
                    message,
                    currentTime,
                    startTime,
                ) = updateQueue.get_nowait()

                curCount = int(curCount)
                maxCount = int(maxCount)
                elapsedTime = currentTime - startTime

                progressFrame.progressBar["maximum"] = maxCount
                progressFrame.progressBar["value"] = curCount

                # Calculate transfer statistics
                if elapsedTime > 0:
                    # Calculate speed and ETA
                    speed = curCount / elapsedTime

                    # Calculate ETA
                    remaining = maxCount - curCount

                    if speed > 0:
                        etaSeconds = remaining / speed
                        etaMinutes = int(etaSeconds // 60)
                        etaSecondsRem = int(etaSeconds % 60)
                        if etaMinutes > 0:
                            etaStr = f"eta: {etaMinutes}m {etaSecondsRem}s"
                        else:
                            etaStr = f"eta: {etaSecondsRem}s"
                    else:
                        etaStr = "eta: guessing ..."

                    # Format elapsed time
                    elapsedMinutes = int(elapsedTime // 60)
                    elapsedSecondsRem = int(elapsedTime % 60)

                    if elapsedMinutes > 0:
                        elapsedStr = f"{elapsedMinutes}m {elapsedSecondsRem}s"
                    else:
                        elapsedStr = f"{elapsedSecondsRem}s"

                    # Update info label
                    infoText = f"{msgType}: {curCount}/{maxCount} ({elapsedStr} {etaStr}) {message}"
                    progressFrame.infoLabel.config(text=infoText)

            except queue.Empty:
                break

        # Check if git operations are done
        if not doneQueue.empty():
            status, error = doneQueue.get()
            if status == "error":
                progressFrame.infoLabel.config(text=f"Error: {error}")
                logger.error(f"Error: {error}")
            else:
                logger.info("Assets updated successfully.")
                progressFrame.infoLabel.config(
                    text="Assets updated successfully!"
                )
                time.sleep(1)
                if doneCB:
                    doneCB()
            return

        # Schedule next check
        mainWindow.root.after(100, checkUpdates)

    # Start checking for updates
    mainWindow.root.after(100, checkUpdates)


if __name__ == "__main__":
    mainWindow = MainWindow()
    mainWindow.show()

    # `pip install opengate-game` puts `og` on PATH via script-files (see
    # pyproject.toml) and already ships ogre-python from PyPI, so there is
    # nothing left to download/unpack -- only the assets repo still needs
    # managing.
    assetsPath = resolveAssetsPath(insource)
    if insource:
        logger.info("Insource run .. skipping binary downloads.")
        mainWindow.addProgressFrame("assets", "Assets")
    elif runMode == PIP:
        logger.info("Pip-installed run .. skipping binary downloads.")
        mainWindow.addProgressFrame("assets", "Assets")
    else:
        logger.info("Binary run .. checking binary downloads.")
        opengatePath = Path(appPath) / "opengate"
        opengateExePath = Path(appPath) / "opengate.exe"

        if not opengatePath.exists() and not opengateExePath.exists():
            logger.info(
                f"{opengatePath} and {opengateExePath} not found .. selecting game directory."
            )
            mainWindow.show()
            appPath = filedialog.askdirectory(
                parent=mainWindow.root,
                title="Select game directory.",
                initialdir=appPath,
            )
            mainWindow.hide()

        try:
            logger.info(f"Fetching manifest from {MANIFESTURL}...")

            # Create SSL context that handles certificate issues
            ssl_context = ssl.create_default_context()
            try:
                with urllib.request.urlopen(  # noqa: S310 -- MANIFESTURL is our own fixed update endpoint
                    MANIFESTURL, context=ssl_context
                ) as response:
                    manifestData = response.read().decode("utf-8")
                    logger.info(f"Manifest data: {manifestData}")
                    manifest = tomllib.loads(manifestData)
                    logger.info(f"Manifest dictionary: {manifest}")
            except urllib.error.URLError:
                # Fallback: disable certificate verification if it fails
                logger.warning(
                    "SSL certificate verification failed, retrying without verification..."
                )
                ssl_context = ssl._create_unverified_context()  # noqa: S323 -- fallback for hosts with broken CA bundles
                with urllib.request.urlopen(  # noqa: S310 -- MANIFESTURL is our own fixed update endpoint
                    MANIFESTURL, context=ssl_context
                ) as response:
                    manifestData = response.read().decode("utf-8")
                    # logger.info(f"Manifest data: \n{manifestData}")
                    manifest = tomllib.loads(manifestData)
                    logger.info(f"Manifest: \n {manifest}")
        except Exception as e:
            logger.error(f"Failed to fetch manifest: {e} (type(e): {type(e)})")
            manifest = {}
            sys.exit()

        downloadsPath = Path(appPath) / ".downloads"
        downloadsPath.mkdir(parents=True, exist_ok=True)

        # Show main window and add progress frames
        mainWindow.addProgressFrame("launcher", "Launcher Update")
        mainWindow.addProgressFrame("system", "Opengate core system")
        mainWindow.addProgressFrame("opengate", "Opengate")
        mainWindow.addProgressFrame("assets", "Assets")
        mainWindow.show()
        time.sleep(1)

        if "Launcher" in manifest:
            download(
                manifest["Launcher"], downloadsPath, "launcher", mainWindow
            )
        if "Opengate_System" in manifest:
            download(
                manifest["Opengate_System"], downloadsPath, "system", mainWindow
            )
        if "Opengate" in manifest:
            download(
                manifest["Opengate"], downloadsPath, "opengate", mainWindow
            )

    # Wait for assets update to complete
    def waitAndLaunch():
        """Wait a bit and then launch the game."""
        time.sleep(1)
        mainWindow.destroy()

        # Launch the actual game
        opengatePath = Path(appPath) / "opengate"
        if sys.platform == "win32" and not opengatePath.exists():
            opengatePath = Path(appPath) / "opengate.exe"

        # Assets are managed separately from the code/binaries (see
        # resolveAssetsPath), so the game process is told where to find them
        # instead of assuming a sibling ``assets/`` directory exists.
        env = os.environ.copy()
        env["OPENGATE_ASSETS"] = str(assetsPath)

        if insource:
            # ``og`` is an extensionless script: Windows cannot execute it
            # directly, so run it with the interpreter that runs the launcher
            # (also keeps the venv on Linux).
            ogPath = Path(appPath) / "og"
            logger.info(f"Launching game: {ogPath}")
            Popen([sys.executable, str(ogPath)], env=env, cwd=appPath)  # noqa: S603 -- launching our own checkout
        elif runMode == PIP:
            pipOgPath = findPipOg()
            if pipOgPath is None:
                logger.error("Could not find the installed `og` script.")
                return
            logger.info(f"Launching game: {pipOgPath}")
            # Extensionless script (no .exe wrapper): Windows cannot execute it
            # directly, so run it through the launcher's interpreter.
            if pipOgPath.lower().endswith(".exe"):
                cmd = [pipOgPath]
            else:
                cmd = [sys.executable, pipOgPath]
            Popen(cmd, env=env)  # noqa: S603 -- launching our own pip-installed script
        elif opengatePath.exists():
            logger.info(f"Launching game: {opengatePath}")
            Popen(str(opengatePath), env=env)  # noqa: S603 -- launching our own verified binary

    updateAssets(assetsPath, mainWindow, doneCB=waitAndLaunch)
    logger.info("Starting main event loop")
    mainWindow.root.mainloop()
    logger.info("=== Launcher shutdown ===")
