#!/usr/bin/env python3

import fnmatch
import hashlib
import json
import os
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path


BASE_DIR = Path("/opt/rpgmultiplayer/updates/chaapaai")

SOURCE_DIR = BASE_DIR / "source"
VERSIONS_DIR = BASE_DIR / "versions"
LAUNCHER_DIR = BASE_DIR / "launcher"
RELEASE_LAUNCHER_DIR = BASE_DIR / "release_launcher"

LATEST_FILE = BASE_DIR / "latest.json"
EXCLUDE_FILE = SOURCE_DIR / "update_exclude.txt"

LAUNCHER_EXE = "Chaapaai_Launcher.exe"

EXCLUDED_DIRS = {
    ".git",
    "__pycache__",
}

# Diese Dateien gehören nicht in ein Spiel-Update.
LOCAL_ONLY_FILES = {
    "manifest.json",
    "update_exclude.txt",
    "Chaapaai_Launcher.py",
    "version.txt",
}

VERSION_RE = re.compile(
    r"^\d+\.\d+\.\d+(?:[A-Za-z][A-Za-z0-9]*|-[A-Za-z0-9][A-Za-z0-9.-]*)?$"
)


def die(message):
    print()
    print("ERROR:", message)
    sys.exit(1)


def validate_version(version):
    if not VERSION_RE.fullmatch(version):
        die(
            "Ungültige Version: "
            + repr(version)
            + "  Beispiele: 1.2.1, 1.2.1a, 1.2.1beta2, 1.2.1-rc1"
        )


def sha256_file(path):
    digest = hashlib.sha256()

    with path.open("rb") as f:
        while True:
            chunk = f.read(1024 * 1024)

            if not chunk:
                break

            digest.update(chunk)

    return digest.hexdigest()


def load_latest():
    if not LATEST_FILE.exists():
        die("latest.json fehlt: " + str(LATEST_FILE))

    try:
        with LATEST_FILE.open("r", encoding="utf-8") as f:
            data = json.load(f)
    except Exception as exc:
        die("latest.json konnte nicht gelesen werden: " + str(exc))

    if not isinstance(data, dict):
        die("latest.json enthält kein JSON-Objekt.")

    return data


def backup_latest():
    timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")

    backup = BASE_DIR / (
        "latest.json.bak-" + timestamp
    )

    shutil.copy2(LATEST_FILE, backup)

    return backup


def write_latest(data):
    temp_file = BASE_DIR / ".latest.json.tmp"

    temp_file.write_text(
        json.dumps(
            data,
            indent=2,
            ensure_ascii=True,
        ) + "\n",
        encoding="utf-8",
    )

    os.replace(temp_file, LATEST_FILE)


def load_exclude_patterns():
    patterns = []

    if not EXCLUDE_FILE.exists():
        return patterns

    with EXCLUDE_FILE.open(
        "r",
        encoding="utf-8-sig",
    ) as f:

        for raw_line in f:
            line = raw_line.strip()

            if not line:
                continue

            if line.startswith("#"):
                continue

            line = line.replace("\\", "/")

            if line.startswith("./"):
                line = line[2:]

            patterns.append(line)

    return patterns


def is_excluded(relative_path, patterns):
    path_string = relative_path.as_posix()
    filename = relative_path.name

    for pattern in patterns:
        if fnmatch.fnmatch(
            path_string.lower(),
            pattern.lower(),
        ):
            return True

        if fnmatch.fnmatch(
            filename.lower(),
            pattern.lower(),
        ):
            return True

    return False


def collect_game_files():
    patterns = load_exclude_patterns()

    files = {}
    excluded = []

    for root, dirs, filenames in os.walk(SOURCE_DIR):

        dirs[:] = [
            d
            for d in dirs
            if d not in EXCLUDED_DIRS
        ]

        for filename in filenames:

            if filename in LOCAL_ONLY_FILES:
                excluded.append(filename)
                continue

            full_path = Path(root) / filename

            relative_path = full_path.relative_to(
                SOURCE_DIR
            )

            if is_excluded(
                relative_path,
                patterns,
            ):
                excluded.append(
                    relative_path.as_posix()
                )
                continue

            relative_string = (
                relative_path.as_posix()
            )

            files[relative_string] = {
                "size": full_path.stat().st_size,
                "sha256": sha256_file(full_path),
            }

    return files, sorted(set(excluded))


def copy_game_files(release_dir, files):

    files_dir = release_dir / "files"

    for relative_string in files:

        source = SOURCE_DIR / relative_string
        destination = (
            files_dir / relative_string
        )

        destination.parent.mkdir(
            parents=True,
            exist_ok=True,
        )

        shutil.copy2(
            source,
            destination,
        )


def write_manifest(
    release_dir,
    version,
    files,
):

    manifest = {
        "project": "chaapaai",
        "version": version,
        "file_count": len(files),
        "files": files,
    }

    manifest_path = (
        release_dir / "manifest.json"
    )

    manifest_path.write_text(
        json.dumps(
            manifest,
            indent=2,
            ensure_ascii=True,
        ) + "\n",
        encoding="utf-8",
    )

    return manifest_path


def publish_game(version):

    validate_version(version)

    if not SOURCE_DIR.exists():
        die(
            "Source-Verzeichnis fehlt: "
            + str(SOURCE_DIR)
        )

    release_dir = (
        VERSIONS_DIR / version
    )

    if release_dir.exists():
        die(
            "Release existiert bereits: "
            + str(release_dir)
        )

    latest = load_latest()

    print()
    print("==============================================")
    print("Chaapa'ai GAME Release")
    print("==============================================")
    print("Aktuelle Spielversion:",
          latest.get("version", "?"))
    print("Neue Spielversion:    ", version)
    print("Launcher-Version:     ",
          latest.get("launcher_version", "?"))
    print()

    files, excluded = collect_game_files()

    print("Dateien im Release:", len(files))
    print("Ausgeschlossen:    ", len(excluded))
    print()

    release_dir.mkdir(
        parents=True,
        exist_ok=False,
    )

    try:

        print("Kopiere Spieldateien...")

        copy_game_files(
            release_dir,
            files,
        )

        print("Erstelle Manifest...")

        manifest_path = write_manifest(
            release_dir,
            version,
            files,
        )

        # Erst jetzt latest.json ändern.
        backup = backup_latest()

        latest["project"] = "chaapaai"
        latest["version"] = version
        latest["manifest"] = (
            f"versions/{version}/manifest.json"
        )
        latest["files"] = (
            f"versions/{version}/files/"
        )

        write_latest(latest)

    except Exception:

        print()
        print("FEHLER beim Release.")

        try:
            shutil.rmtree(release_dir)
        except Exception:
            pass

        raise

    print()
    print("==============================================")
    print("GAME-RELEASE ERFOLGREICH")
    print("==============================================")
    print("Spielversion:", version)
    print("Dateien:     ", len(files))
    print("Manifest:    ", manifest_path)
    print("Backup:      ", backup)
    print()
    print("Launcher blieb unverändert:")
    print(
        latest.get(
            "launcher_version",
            "nicht gesetzt",
        )
    )


def publish_launcher(version):

    validate_version(version)

    source = (
        RELEASE_LAUNCHER_DIR /
        LAUNCHER_EXE
    )

    if not source.exists():
        die(
            "Launcher-EXE fehlt: "
            + str(source)
        )

    destination_dir = (
        LAUNCHER_DIR / version
    )

    destination = (
        destination_dir /
        LAUNCHER_EXE
    )

    if destination_dir.exists():
        die(
            "Launcher-Version existiert bereits: "
            + str(destination_dir)
        )

    latest = load_latest()

    print()
    print("==============================================")
    print("Chaapa'ai LAUNCHER Release")
    print("==============================================")
    print("Aktuelle Launcher-Version:",
          latest.get("launcher_version", "?"))
    print("Neue Launcher-Version:    ", version)
    print("Spielversion bleibt:      ",
          latest.get("version", "?"))
    print()

    print(
        "Launcher SHA256:",
        sha256_file(source),
    )
    print()

    destination_dir.mkdir(
        parents=True,
        exist_ok=False,
    )

    try:

        shutil.copy2(
            source,
            destination,
        )

        if sha256_file(source) != sha256_file(destination):
            raise RuntimeError(
                "SHA256 nach dem Kopieren stimmt nicht überein."
            )

        backup = backup_latest()

        latest["project"] = "chaapaai"
        latest["launcher"] = (
            f"launcher/{version}/{LAUNCHER_EXE}"
        )
        latest["launcher_version"] = version

        # launcher_updater wird bewusst NICHT verändert.
        write_latest(latest)

    except Exception:

        try:
            shutil.rmtree(destination_dir)
        except Exception:
            pass

        raise

    print()
    print("==============================================")
    print("LAUNCHER-RELEASE ERFOLGREICH")
    print("==============================================")
    print("Launcher-Version:", version)
    print("Datei:           ", destination)
    print("SHA256:          ", sha256_file(destination))
    print("Backup:          ", backup)
    print()
    print("Spielversion blieb unverändert:")
    print(latest.get("version", "?"))


def show_status():

    latest = load_latest()

    print()
    print("==============================================")
    print("CHAAPA'AI RELEASE STATUS")
    print("==============================================")
    print()

    print(
        "Spielversion:      ",
        latest.get("version", "NICHT GESETZT"),
    )

    print(
        "Manifest:          ",
        latest.get("manifest", "NICHT GESETZT"),
    )

    print(
        "Dateien:           ",
        latest.get("files", "NICHT GESETZT"),
    )

    print()

    print(
        "Launcher-Version:  ",
        latest.get(
            "launcher_version",
            "NICHT GESETZT",
        ),
    )

    print(
        "Launcher:          ",
        latest.get(
            "launcher",
            "NICHT GESETZT",
        ),
    )

    print(
        "Launcher-Updater:  ",
        latest.get(
            "launcher_updater",
            "NICHT GESETZT",
        ),
    )

    print()

    if SOURCE_DIR.exists():
        print("Source:             OK")
    else:
        print("Source:             FEHLT")

    if EXCLUDE_FILE.exists():
        print("Exclude-Liste:      OK")
    else:
        print("Exclude-Liste:      FEHLT")

    launcher_source = (
        RELEASE_LAUNCHER_DIR /
        LAUNCHER_EXE
    )

    if launcher_source.exists():
        print("Release-Launcher:   OK")
        print(
            "Launcher SHA256:    ",
            sha256_file(launcher_source),
        )
    else:
        print("Release-Launcher:   FEHLT")

    print()
    print("==============================================")


def usage():

    print()
    print("Usage:")
    print()
    print("  python3 publish.py status")
    print()
    print("  python3 publish.py game 1.2.1")
    print()
    print("  python3 publish.py launcher 1.2.2")
    print()


def main():

    if len(sys.argv) < 2:
        usage()
        sys.exit(1)

    command = sys.argv[1].strip().lower()

    if command == "status":

        if len(sys.argv) != 2:
            usage()
            sys.exit(1)

        show_status()
        return

    if command == "game":

        if len(sys.argv) != 3:
            usage()
            sys.exit(1)

        publish_game(
            sys.argv[2].strip()
        )
        return

    if command == "launcher":

        if len(sys.argv) != 3:
            usage()
            sys.exit(1)

        publish_launcher(
            sys.argv[2].strip()
        )
        return

    usage()
    sys.exit(1)


if __name__ == "__main__":
    main()
