#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
mz_zipper for Windows

Mac用に書き出したゲームフォルダを、Macで正常に起動するzipにまとめます。
フォルダをこのプログラムにドラッグ＆ドロップしてください。

macOSのアプリには「実行権限」と「シンボリックリンク」という情報が必要ですが、
Windowsのファイルシステムはどちらも保存できません。そのためWindowsで
書き出したMac版は、その時点で壊れています。

zipファイル自体はどちらの情報も表現できるため、このツールは
ファイルシステムの属性を一切参照せず、ファイルの中身から必要な情報を
判断して、zipに直接書き込みます。入力フォルダは変更しません。
"""

import os
import sys
import time
import stat
import zipfile
import posixpath

VERSION = "1.0"

# Mach-O 実行ファイルのマジックナンバー（ファイル先頭4バイト）
MACHO_MAGICS = {
    b"\xcf\xfa\xed\xfe",  # 64bit little endian
    b"\xce\xfa\xed\xfe",  # 32bit little endian
    b"\xfe\xed\xfa\xcf",  # 64bit big endian
    b"\xfe\xed\xfa\xce",  # 32bit big endian
    b"\xca\xfe\xba\xbe",  # universal binary
    b"\xbe\xba\xfe\xca",  # universal binary (swapped)
}

MAX_LINK_SIZE = 512      # シンボリックリンクの実体は短いパス文字列にすぎない
MODE_LINK = 0o120777
MODE_EXEC = 0o100755
MODE_FILE = 0o100644
MODE_DIR = 0o040755

SKIP_NAMES = {".DS_Store", "Thumbs.db", "desktop.ini"}


def is_macho(path):
    """先頭4バイトを読んでMach-O実行ファイルか判定する。
    Windowsには実行ビットが無いため、ファイルの中身で判定する。"""
    try:
        with open(path, "rb") as f:
            return f.read(4) in MACHO_MAGICS
    except OSError:
        return False


def link_candidate(path):
    """「リンク先パスが書かれただけのテキストファイル」の候補か調べ、中身を返す。

    Windowsで展開されたシンボリックリンクは、リンク先のパスを本文に持つ
    数十バイトの通常ファイルになっている。
    """
    try:
        size = os.path.getsize(path)
    except OSError:
        return None
    if size == 0 or size > MAX_LINK_SIZE:
        return None
    try:
        with open(path, "rb") as f:
            text = f.read().decode("utf-8")
    except (OSError, UnicodeDecodeError):
        return None
    if "\n" in text or "\r" in text or "\x00" in text:
        return None
    text = text.strip()
    if not text or text.startswith("/"):
        return None
    return text


def resolve_through(path, cands, depth=0):
    """化けたリンクを辿りながら、パスが最終的に実在するか確認する。

    Helpers -> Versions/Current/Helpers のように、途中の Versions/Current 自体も
    化けている場合があるため、中間要素も候補表を使って展開する。
    """
    if depth > 20:
        return False
    if os.path.lexists(path):
        return True
    parts = path.split(os.sep)
    for i in range(len(parts) - 1, 0, -1):
        prefix = os.sep.join(parts[:i])
        if prefix in cands:
            expanded = os.path.normpath(
                os.path.join(os.path.dirname(prefix), cands[prefix])
            )
            return resolve_through(
                os.path.normpath(os.path.join(expanded, *parts[i:])), cands, depth + 1
            )
    return False


def collect_links(src_dir):
    """フォルダ全体を走査し、テキストファイルに化けたシンボリックリンクを確定する。

    1回目で候補を集め、2回目でリンク先の実在を検証する。リンクが連鎖しているため
    この2段階が必要になる。実在確認があるので、たまたまパスに見えるだけの
    データファイルは対象にならない。
    """
    cands = {}
    for dirpath, _, filenames in os.walk(src_dir):
        for name in filenames:
            path = os.path.join(dirpath, name)
            t = link_candidate(path)
            if t is not None:
                cands[path] = t

    confirmed = {}
    root = os.path.abspath(src_dir)
    for path, target in cands.items():
        resolved = os.path.normpath(os.path.join(os.path.dirname(path), target))
        try:
            if os.path.commonpath([os.path.abspath(resolved), root]) != root:
                continue  # フォルダの外を指すものは対象外
        except ValueError:
            continue
        if resolve_through(resolved, cands):
            confirmed[path] = target
    return confirmed


def make_zip(src_dir):
    """フォルダをzipにまとめ、(リンク数, 実行ファイル数, 通常ファイル数, 出力先) を返す。"""
    src = os.path.abspath(src_dir.rstrip("/\\"))
    parent = os.path.dirname(src)
    top = os.path.basename(src)
    out_zip = os.path.join(parent, top + ".zip")

    links = collect_links(src)
    n_link = n_exec = n_file = 0

    def add(zf, abs_path, mode, data=None, is_dir=False):
        rel = os.path.relpath(abs_path, parent)
        arc = posixpath.join(*rel.split(os.sep))
        if is_dir:
            arc += "/"
        zi = zipfile.ZipInfo(arc)
        zi.create_system = 3          # 3 = Unix。これが無いと権限が読まれない
        zi.external_attr = (mode << 16)
        if is_dir:
            zi.external_attr |= 0x10  # MS-DOS のディレクトリ属性
            zf.writestr(zi, b"")
            return
        if stat.S_ISLNK(mode):
            zi.compress_type = zipfile.ZIP_STORED
            zf.writestr(zi, data)
            return
        zi.compress_type = zipfile.ZIP_DEFLATED
        with open(abs_path, "rb") as f:
            zf.writestr(zi, f.read())

    with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as zf:
        for dirpath, dirnames, filenames in os.walk(src):
            dirnames.sort()
            filenames.sort()
            add(zf, dirpath, MODE_DIR, is_dir=True)

            for name in filenames:
                if name in SKIP_NAMES:
                    continue
                path = os.path.join(dirpath, name)

                target = links.get(path)
                if target is not None:
                    add(zf, path, MODE_LINK, data=target.encode("utf-8"))
                    n_link += 1
                elif is_macho(path):
                    add(zf, path, MODE_EXEC)
                    n_exec += 1
                else:
                    add(zf, path, MODE_FILE)
                    n_file += 1

    return n_link, n_exec, n_file, out_zip


def setup_console():
    if os.name == "nt":
        os.system("chcp 65001 >nul 2>&1")
    for s in (sys.stdout, sys.stderr):
        try:
            s.reconfigure(encoding="utf-8")
        except Exception:
            pass


def pause():
    print()
    try:
        input("Enterキーを押すと終了します...")
    except EOFError:
        pass


def main():
    setup_console()
    print()
    print("=" * 58)
    print("  mz_zipper for Windows   version " + VERSION)
    print("  Mac版ゲームフォルダを配布用zipにまとめます")
    print("=" * 58)
    print()

    targets = [a for a in sys.argv[1:] if a.strip()]

    if not targets:
        print("ゲームフォルダをこのウィンドウにドラッグ＆ドロップして、")
        print("Enterキーを押してください。")
        print()
        try:
            line = input("フォルダ: ").strip()
        except EOFError:
            line = ""
        line = line.strip('"').strip("'")
        if line:
            targets = [line]

    if not targets:
        print()
        print("フォルダが指定されませんでした。")
        pause()
        return 1

    ok = err = 0
    for src in targets:
        src = src.rstrip("/\\")
        name = os.path.basename(os.path.abspath(src))
        print("-" * 58)
        if not os.path.isdir(src):
            print("  スキップ: フォルダが見つかりません")
            print("    " + src)
            err += 1
            continue

        print("  " + name + " を圧縮しています...")
        start = time.time()
        try:
            n_link, n_exec, n_file, out_zip = make_zip(src)
        except Exception as e:
            print("  エラー: 圧縮に失敗しました")
            print("    " + str(e))
            err += 1
            continue
        sec = int(time.time() - start)
        mb = os.path.getsize(out_zip) / 1024 / 1024

        print()
        print("  完了しました（{}秒）".format(sec))
        print("    シンボリックリンク : {} 個".format(n_link))
        print("    実行ファイル(755)  : {} 個".format(n_exec))
        print("    通常ファイル(644)  : {} 個".format(n_file))
        print("    出力 : {}".format(os.path.basename(out_zip)))
        print("    場所 : {}".format(os.path.dirname(out_zip)))
        print("    サイズ : {:.1f} MB".format(mb))

        if n_exec == 0:
            print()
            print("  ※ 実行ファイルが見つかりませんでした。")
            print("     Mac用に書き出されたフォルダか確認してください。")
        ok += 1

    print("-" * 58)
    print()
    if ok:
        print("{} 個のzipを作成しました。そのまま配布できます。".format(ok))
        print()
        print("※ ダウンロードした方は初回のみ、Game.app を右クリックして")
        print("   「開く」を選ぶ必要があります。")
        print("   （Appleの署名がないアプリに共通の仕様です）")
    if err:
        print()
        print("{} 個は処理できませんでした。".format(err))
    pause()
    return 0 if ok else 1


if __name__ == "__main__":
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        print()
        print("中断しました。")
        sys.exit(1)
