#!/usr/bin/env python3
from __future__ import annotations

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

from PyQt5.QtCore import QProcess, QProcessEnvironment, Qt
from PyQt5.QtWidgets import (
    QApplication,
    QComboBox,
    QFileDialog,
    QGridLayout,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QMainWindow,
    QMessageBox,
    QPushButton,
    QPlainTextEdit,
    QSizePolicy,
    QVBoxLayout,
    QWidget,
)


SCRIPT_DIR = Path(__file__).resolve().parent
CLI_SCRIPT = SCRIPT_DIR / "RestoreGameAssets.py"
PROFILE_DIR = SCRIPT_DIR / "restore_profiles"
LOG_DIR = SCRIPT_DIR / "restore_logs"


def sanitize_name(value: str) -> str:
    cleaned = re.sub(r"[^\w.-]+", "_", value.strip(), flags=re.UNICODE)
    return cleaned.strip("._") or "last_used"


def guess_project_root(input_path: str | Path) -> Path:
    path = Path(input_path).expanduser()
    if path.is_file():
        path = path.parent
    path = path.resolve()

    if (path / "assets" / "assets").exists() or (path / "files" / "GameAssets" / "assets").exists() or (path / "files" / "gamecaches").exists():
        return path

    lower_parts = [part.lower() for part in path.parts]
    if len(lower_parts) >= 2 and lower_parts[-2:] == ["assets", "assets"]:
        return path.parent.parent
    if len(lower_parts) >= 3 and lower_parts[-3:] == ["files", "gameassets", "assets"]:
        return path.parent.parent.parent
    if len(lower_parts) >= 2 and lower_parts[-2:] == ["files", "gamecaches"]:
        return path.parent.parent

    return path


def find_relative_dir(base: Path, relative_parts: tuple[str, ...]) -> Path | None:
    direct = base.joinpath(*relative_parts)
    if direct.exists():
        return direct

    tail = [part.lower() for part in relative_parts]
    last_part = relative_parts[-1]
    for candidate in base.rglob(last_part):
        if not candidate.is_dir():
            continue
        try:
            parts = [part.lower() for part in candidate.relative_to(base).parts]
        except Exception:
            continue
        if len(parts) >= len(tail) and parts[-len(tail) :] == tail:
            return candidate
    return None


def detect_spine_config(gamecache_root: Path, spine_bundle_name: str) -> Path | None:
    bundle_root = gamecache_root / spine_bundle_name
    if not bundle_root.exists():
        return None

    default_candidate = bundle_root / "177385225976168.json"
    if default_candidate.exists():
        return default_candidate

    for candidate in sorted(bundle_root.glob("*.json"), key=lambda item: item.name.lower()):
        try:
            obj = json.loads(candidate.read_text(encoding="utf-8"))
        except Exception:
            continue
        if isinstance(obj, dict) and isinstance(obj.get("paths"), dict) and isinstance(obj.get("uuids"), list):
            return candidate
    return None


class PathRow(QWidget):
    def __init__(self, label: str, choose_dir: bool = True, parent: QWidget | None = None) -> None:
        super().__init__(parent)
        self.choose_dir = choose_dir
        self.label = QLabel(label)
        self.edit = QLineEdit()
        self.edit.setClearButtonEnabled(True)
        self.button = QPushButton("浏览")
        self.button.clicked.connect(self.pick_path)

        layout = QHBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.addWidget(self.label)
        layout.addWidget(self.edit, 1)
        layout.addWidget(self.button)

    def pick_path(self) -> None:
        if self.choose_dir:
            path = QFileDialog.getExistingDirectory(self, "选择目录", self.edit.text() or str(SCRIPT_DIR))
        else:
            path, _ = QFileDialog.getOpenFileName(self, "选择文件", self.edit.text() or str(SCRIPT_DIR))
        if path:
            self.edit.setText(path)

    def text(self) -> str:
        return self.edit.text().strip()

    def set_text(self, value: str) -> None:
        self.edit.setText(value)


class MainWindow(QMainWindow):
    def __init__(self, initial_path: str | None = None) -> None:
        super().__init__()
        self.process: QProcess | None = None
        self.log_handle = None
        self.current_log_path: Path | None = None

        PROFILE_DIR.mkdir(parents=True, exist_ok=True)
        LOG_DIR.mkdir(parents=True, exist_ok=True)

        self.setWindowTitle("同类 Cocos 资源还原工具")
        self.resize(1180, 860)
        self.setAcceptDrops(True)

        central = QWidget()
        self.setCentralWidget(central)
        root_layout = QVBoxLayout(central)

        profile_group = QGroupBox("配置档 / 字库")
        profile_layout = QGridLayout(profile_group)
        self.profile_combo = QComboBox()
        self.profile_combo.setEditable(True)
        self.profile_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
        self.btn_refresh_profile = QPushButton("刷新")
        self.btn_load_profile = QPushButton("载入")
        self.btn_save_profile = QPushButton("保存")
        self.btn_refresh_profile.clicked.connect(self.refresh_profiles)
        self.btn_load_profile.clicked.connect(self.load_profile)
        self.btn_save_profile.clicked.connect(self.save_profile)
        profile_layout.addWidget(QLabel("配置档名称"), 0, 0)
        profile_layout.addWidget(self.profile_combo, 0, 1)
        profile_layout.addWidget(self.btn_refresh_profile, 0, 2)
        profile_layout.addWidget(self.btn_load_profile, 0, 3)
        profile_layout.addWidget(self.btn_save_profile, 0, 4)

        path_group = QGroupBox("目录识别")
        path_layout = QVBoxLayout(path_group)
        self.project_root_row = PathRow("项目根目录")
        self.apk_assets_row = PathRow("APK assets目录")
        self.gameassets_row = PathRow("热更新 GameAssets目录")
        self.gamecache_row = PathRow("热更新 gamecaches目录")
        self.output_row = PathRow("输出目录")
        self.astcenc_row = PathRow("astcenc工具", choose_dir=False)
        self.spine_config_row = PathRow("Spine配置json", choose_dir=False)
        for row in [
            self.project_root_row,
            self.apk_assets_row,
            self.gameassets_row,
            self.gamecache_row,
            self.output_row,
            self.astcenc_row,
            self.spine_config_row,
        ]:
            path_layout.addWidget(row)

        rule_group = QGroupBox("解密规则")
        rule_layout = QGridLayout(rule_group)
        self.animation_bundle_edit = QLineEdit("AnimationSpine")
        self.spine_bundle_edit = QLineEdit("Spine")
        self.signature_edit = QLineEdit("beeplay")
        self.xor_default_edit = QLineEdit("0x17")
        self.xor_image_edit = QLineEdit("0x44")
        self.only_bundles_edit = QLineEdit()
        rule_layout.addWidget(QLabel("动画Bundle名"), 0, 0)
        rule_layout.addWidget(self.animation_bundle_edit, 0, 1)
        rule_layout.addWidget(QLabel("Spine缓存名"), 0, 2)
        rule_layout.addWidget(self.spine_bundle_edit, 0, 3)
        rule_layout.addWidget(QLabel("Beeplay签名"), 1, 0)
        rule_layout.addWidget(self.signature_edit, 1, 1)
        rule_layout.addWidget(QLabel("默认XOR"), 1, 2)
        rule_layout.addWidget(self.xor_default_edit, 1, 3)
        rule_layout.addWidget(QLabel("图片XOR"), 2, 0)
        rule_layout.addWidget(self.xor_image_edit, 2, 1)
        rule_layout.addWidget(QLabel("仅还原这些Bundle"), 2, 2)
        rule_layout.addWidget(self.only_bundles_edit, 2, 3)

        action_group = QGroupBox("操作")
        action_layout = QHBoxLayout(action_group)
        self.btn_auto_detect = QPushButton("自动识别")
        self.btn_start = QPushButton("开始还原")
        self.btn_stop = QPushButton("停止")
        self.btn_open_output = QPushButton("打开输出目录")
        self.btn_clean_backups = QPushButton("清理旧备份")
        self.btn_auto_detect.clicked.connect(self.auto_detect)
        self.btn_start.clicked.connect(self.start_restore)
        self.btn_stop.clicked.connect(self.stop_restore)
        self.btn_open_output.clicked.connect(self.open_output)
        self.btn_clean_backups.clicked.connect(self.clean_backups)
        self.btn_stop.setEnabled(False)
        for button in [
            self.btn_auto_detect,
            self.btn_start,
            self.btn_stop,
            self.btn_open_output,
            self.btn_clean_backups,
        ]:
            action_layout.addWidget(button)

        self.log_box = QPlainTextEdit()
        self.log_box.setReadOnly(True)
        self.log_box.setLineWrapMode(QPlainTextEdit.NoWrap)

        self.status_label = QLabel("就绪")
        self.status_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)

        self.drop_hint_label = QLabel("支持把游戏解压目录、assets/assets、files/GameAssets/assets、files/gamecaches 直接拖进窗口")
        self.drop_hint_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)

        root_layout.addWidget(profile_group)
        root_layout.addWidget(path_group)
        root_layout.addWidget(rule_group)
        root_layout.addWidget(action_group)
        root_layout.addWidget(self.drop_hint_label)
        root_layout.addWidget(QLabel("日志"))
        root_layout.addWidget(self.log_box, 1)
        root_layout.addWidget(self.status_label)

        default_root = guess_project_root(initial_path) if initial_path else SCRIPT_DIR
        self.project_root_row.set_text(str(default_root))
        self.output_row.set_text(str(default_root / "pack" / "restored"))
        self.astcenc_row.set_text(str((SCRIPT_DIR / "bin" / "astcenc-avx2.exe").resolve()))
        self.refresh_profiles()
        self.auto_detect()

    def dragEnterEvent(self, event) -> None:  # noqa: N802
        if event.mimeData().hasUrls():
            event.acceptProposedAction()
            return
        event.ignore()

    def dropEvent(self, event) -> None:  # noqa: N802
        urls = event.mimeData().urls()
        if not urls:
            event.ignore()
            return

        local_path = urls[0].toLocalFile()
        if not local_path:
            event.ignore()
            return

        project_root = guess_project_root(local_path)
        self.project_root_row.set_text(str(project_root))
        self.auto_detect()
        self.append_log(f"[drop] input={local_path}\n")
        self.append_log(f"[drop] project_root={project_root}\n")
        self.status_label.setText(f"已载入拖入目录: {project_root}")
        event.acceptProposedAction()

    def append_log(self, text: str) -> None:
        if not text:
            return
        cleaned = text.rstrip("\r\n")
        if cleaned:
            self.log_box.appendPlainText(cleaned)
        if self.log_handle:
            self.log_handle.write(text)
            self.log_handle.flush()

    def set_running(self, running: bool) -> None:
        self.btn_start.setEnabled(not running)
        self.btn_stop.setEnabled(running)
        self.btn_auto_detect.setEnabled(not running)
        self.btn_save_profile.setEnabled(not running)
        self.btn_load_profile.setEnabled(not running)

    def collect_form_data(self) -> dict:
        return {
            "project_root": self.project_root_row.text(),
            "apk_assets_root": self.apk_assets_row.text(),
            "gameassets_root": self.gameassets_row.text(),
            "gamecache_root": self.gamecache_row.text(),
            "output_root": self.output_row.text(),
            "astcenc_path": self.astcenc_row.text(),
            "spine_config_path": self.spine_config_row.text(),
            "animation_bundle_name": self.animation_bundle_edit.text().strip() or "AnimationSpine",
            "spine_bundle_name": self.spine_bundle_edit.text().strip() or "Spine",
            "beeplay_signature": self.signature_edit.text().strip() or "beeplay",
            "xor_default": self.xor_default_edit.text().strip() or "0x17",
            "xor_image": self.xor_image_edit.text().strip() or "0x44",
            "only_bundles": self.only_bundles_edit.text().strip(),
        }

    def apply_form_data(self, data: dict) -> None:
        self.project_root_row.set_text(data.get("project_root", ""))
        self.apk_assets_row.set_text(data.get("apk_assets_root", ""))
        self.gameassets_row.set_text(data.get("gameassets_root", ""))
        self.gamecache_row.set_text(data.get("gamecache_root", ""))
        self.output_row.set_text(data.get("output_root", ""))
        self.astcenc_row.set_text(data.get("astcenc_path", ""))
        self.spine_config_row.set_text(data.get("spine_config_path", ""))
        self.animation_bundle_edit.setText(data.get("animation_bundle_name", "AnimationSpine"))
        self.spine_bundle_edit.setText(data.get("spine_bundle_name", "Spine"))
        self.signature_edit.setText(data.get("beeplay_signature", "beeplay"))
        self.xor_default_edit.setText(data.get("xor_default", "0x17"))
        self.xor_image_edit.setText(data.get("xor_image", "0x44"))
        self.only_bundles_edit.setText(data.get("only_bundles", ""))

    def refresh_profiles(self) -> None:
        current = self.profile_combo.currentText().strip()
        self.profile_combo.blockSignals(True)
        self.profile_combo.clear()
        for file_path in sorted(PROFILE_DIR.glob("*.json"), key=lambda item: item.name.lower()):
            self.profile_combo.addItem(file_path.stem)
        if current:
            self.profile_combo.setEditText(current)
        elif self.profile_combo.count() == 0:
            self.profile_combo.setEditText("last_used")
        self.profile_combo.blockSignals(False)

    def profile_path(self) -> Path:
        return PROFILE_DIR / f"{sanitize_name(self.profile_combo.currentText())}.json"

    def save_profile(self) -> None:
        profile_path = self.profile_path()
        data = self.collect_form_data()
        profile_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
        self.refresh_profiles()
        self.profile_combo.setEditText(profile_path.stem)
        self.status_label.setText(f"已保存配置档: {profile_path}")
        self.append_log(f"[profile] saved: {profile_path}\n")

    def load_profile(self) -> None:
        profile_path = self.profile_path()
        if not profile_path.exists():
            QMessageBox.warning(self, "配置档不存在", f"没有找到配置档:\n{profile_path}")
            return
        data = json.loads(profile_path.read_text(encoding="utf-8"))
        self.apply_form_data(data)
        self.status_label.setText(f"已载入配置档: {profile_path}")
        self.append_log(f"[profile] loaded: {profile_path}\n")

    def auto_detect(self) -> None:
        project_root_text = self.project_root_row.text() or str(SCRIPT_DIR)
        project_root = Path(project_root_text)
        if not project_root.exists():
            QMessageBox.warning(self, "目录不存在", f"项目根目录不存在:\n{project_root}")
            return

        assets_root = find_relative_dir(project_root, ("assets", "assets"))
        gameassets_root = find_relative_dir(project_root, ("files", "GameAssets", "assets"))
        gamecache_root = find_relative_dir(project_root, ("files", "gamecaches"))
        output_root = project_root / "pack" / "restored"

        astcenc_candidates = [
            SCRIPT_DIR / "bin" / "astcenc-avx2.exe",
            project_root / "bin" / "astcenc-avx2.exe",
        ]
        astcenc_path = next((item for item in astcenc_candidates if item.exists()), astcenc_candidates[0])

        spine_bundle_name = self.spine_bundle_edit.text().strip() or "Spine"
        spine_config_path = detect_spine_config(gamecache_root, spine_bundle_name) if gamecache_root else None

        if assets_root:
            self.apk_assets_row.set_text(str(assets_root))
        if gameassets_root:
            self.gameassets_row.set_text(str(gameassets_root))
        if gamecache_root:
            self.gamecache_row.set_text(str(gamecache_root))
        self.output_row.set_text(str(output_root))
        self.astcenc_row.set_text(str(astcenc_path))
        if spine_config_path:
            self.spine_config_row.set_text(str(spine_config_path))

        self.append_log("[detect] scan finished\n")
        self.append_log(f"[detect] project_root={project_root}\n")
        self.append_log(f"[detect] apk_assets={assets_root or 'not found'}\n")
        self.append_log(f"[detect] gameassets={gameassets_root or 'not found'}\n")
        self.append_log(f"[detect] gamecache={gamecache_root or 'not found'}\n")
        if spine_config_path:
            self.append_log(f"[detect] spine_config={spine_config_path}\n")

        self.status_label.setText("自动识别已完成")

    def validate_before_run(self) -> bool:
        if not CLI_SCRIPT.exists():
            QMessageBox.critical(self, "缺少主脚本", f"没有找到:\n{CLI_SCRIPT}")
            return False

        output_root = Path(self.output_row.text())
        output_root.parent.mkdir(parents=True, exist_ok=True)

        if not any(
            Path(text).exists()
            for text in [
                self.apk_assets_row.text(),
                self.gameassets_row.text(),
                self.gamecache_row.text(),
            ]
            if text
        ):
            QMessageBox.warning(self, "缺少输入目录", "至少需要识别到 APK assets、GameAssets、gamecaches 其中之一。")
            return False
        return True

    def build_environment(self) -> dict[str, str]:
        data = self.collect_form_data()
        env = os.environ.copy()
        env["PYTHONUTF8"] = "1"
        env["PYTHONIOENCODING"] = "utf-8"
        env["RESTORE_ROOT"] = data["project_root"] or str(SCRIPT_DIR)
        env["RESTORE_OUTPUT_DIR"] = data["output_root"]
        env["RESTORE_ASSETS_ROOT"] = data["apk_assets_root"]
        env["RESTORE_GAMEASSETS_ROOT"] = data["gameassets_root"]
        env["RESTORE_GAMECACHE_ROOT"] = data["gamecache_root"]
        env["RESTORE_ASTCENC"] = data["astcenc_path"]
        env["RESTORE_SPINE_CONFIG"] = data["spine_config_path"]
        env["RESTORE_ANIMATION_SPINE_BUNDLE"] = data["animation_bundle_name"]
        env["RESTORE_SPINE_BUNDLE"] = data["spine_bundle_name"]
        env["RESTORE_BEEPLAY_SIGNATURE"] = data["beeplay_signature"]
        env["RESTORE_XOR_KEY_DEFAULT"] = data["xor_default"]
        env["RESTORE_XOR_KEY_IMAGE"] = data["xor_image"]
        env["RESTORE_ONLY_BUNDLES"] = data["only_bundles"]
        return env

    def start_restore(self) -> None:
        if self.process and self.process.state() != QProcess.NotRunning:
            QMessageBox.information(self, "任务运行中", "当前已经有一个还原任务正在运行。")
            return
        if not self.validate_before_run():
            return

        self.save_profile()
        profile_name = sanitize_name(self.profile_combo.currentText())
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        self.current_log_path = LOG_DIR / f"{timestamp}_{profile_name}.log"
        self.log_handle = self.current_log_path.open("w", encoding="utf-8", newline="")

        self.log_box.clear()
        self.append_log(f"[run] log file: {self.current_log_path}\n")
        for key, value in self.build_environment().items():
            if key.startswith("RESTORE_"):
                self.append_log(f"[env] {key}={value}\n")
        self.append_log("\n")

        self.process = QProcess(self)
        self.process.setProgram(sys.executable)
        self.process.setArguments([str(CLI_SCRIPT)])
        self.process.setWorkingDirectory(str(SCRIPT_DIR))

        proc_env = QProcessEnvironment.systemEnvironment()
        for key, value in self.build_environment().items():
            proc_env.insert(key, value)
        self.process.setProcessEnvironment(proc_env)

        self.process.readyReadStandardOutput.connect(self.read_stdout)
        self.process.readyReadStandardError.connect(self.read_stderr)
        self.process.finished.connect(self.on_finished)

        self.set_running(True)
        self.status_label.setText("正在还原...")
        self.process.start()
        if not self.process.waitForStarted(5000):
            self.set_running(False)
            self.close_log_handle()
            QMessageBox.critical(self, "启动失败", "还原进程启动失败。")

    def read_stdout(self) -> None:
        if not self.process:
            return
        data = bytes(self.process.readAllStandardOutput()).decode("utf-8", errors="replace")
        self.append_log(data)

    def read_stderr(self) -> None:
        if not self.process:
            return
        data = bytes(self.process.readAllStandardError()).decode("utf-8", errors="replace")
        self.append_log(data)

    def on_finished(self, exit_code: int, _exit_status) -> None:
        self.set_running(False)
        self.status_label.setText(f"运行结束，退出码: {exit_code}")
        self.append_log(f"\n[run] finished with exit code {exit_code}\n")
        log_path = self.current_log_path
        self.close_log_handle()
        if exit_code == 0:
            QMessageBox.information(self, "完成", f"资源还原完成。\n日志文件:\n{log_path}")
        else:
            QMessageBox.warning(self, "失败", f"资源还原失败，退出码 {exit_code}。\n日志文件:\n{log_path}")

    def close_log_handle(self) -> None:
        if self.log_handle:
            self.log_handle.close()
            self.log_handle = None

    def stop_restore(self) -> None:
        if self.process and self.process.state() != QProcess.NotRunning:
            self.process.kill()
            self.append_log("[run] process killed by user\n")
            self.status_label.setText("任务已停止")

    def open_output(self) -> None:
        output_root_text = self.output_row.text()
        if not output_root_text:
            return
        output_root = Path(output_root_text)
        output_root.mkdir(parents=True, exist_ok=True)
        os.startfile(str(output_root))

    def clean_backups(self) -> None:
        output_root_text = self.output_row.text()
        if not output_root_text:
            QMessageBox.warning(self, "缺少输出目录", "请先填写输出目录。")
            return
        backup_root = Path(output_root_text) / "_partial_backups"
        if not backup_root.exists():
            QMessageBox.information(self, "无需清理", f"没有找到备份目录:\n{backup_root}")
            return
        reply = QMessageBox.question(
            self,
            "确认删除",
            f"将永久删除以下目录:\n{backup_root}",
            QMessageBox.Yes | QMessageBox.No,
            QMessageBox.No,
        )
        if reply != QMessageBox.Yes:
            return
        shutil.rmtree(backup_root, ignore_errors=False)
        self.append_log(f"[cleanup] deleted: {backup_root}\n")
        self.status_label.setText("旧备份已删除")

    def closeEvent(self, event) -> None:  # noqa: N802
        if self.process and self.process.state() != QProcess.NotRunning:
            reply = QMessageBox.question(
                self,
                "任务仍在运行",
                "当前还原任务仍在运行，确定要退出吗？",
                QMessageBox.Yes | QMessageBox.No,
                QMessageBox.No,
            )
            if reply != QMessageBox.Yes:
                event.ignore()
                return
            self.process.kill()
        self.close_log_handle()
        super().closeEvent(event)


def main() -> int:
    initial_path = None
    for arg in sys.argv[1:]:
        if arg and not arg.startswith("--"):
            initial_path = arg
            break

    app = QApplication(sys.argv)
    window = MainWindow(initial_path=initial_path)
    window.show()
    return app.exec_()


if __name__ == "__main__":
    raise SystemExit(main())
