#!/usr/bin/env python3

import sys
import pathlib
import argparse
import re
import os
import shutil
import subprocess
import tempfile
import random
import string
import json
from threading import Event


'''
load bottles from /opt/apps/deepin-wine-builder/files/share/deepin-wine-builder, in dev envrionment, load bottles
from relative path
'''
pkgdatadir="/opt/apps/deepin-wine-builder/files/share/deepin-wine-builder"
if not pkgdatadir.startswith('/'):
    pkgdatadir = pathlib.Path(__file__).parent.parent.absolute().as_posix()
sys.path.insert(1, pkgdatadir)
sys.path.insert(1, pkgdatadir + '/../../lib/python3/dist-packages')

from typing import List, Optional, Tuple

from bottles.backend.utils.singleton import Singleton
from bottles.backend.models.result import Result
from bottles.backend.downloader import ComponentDownloader
from bottles.backend.utils.threading import RunAsync
from bottles.backend.wine.wineserver import WineServer
from bottles.backend.wine.winepath import WinePath
from bottles.backend.globals import Paths

from deepin.runner.installer_utils import InstallerUtils, PEFileVersionInfo
from deepin.utils.chinese_to_pinyin import pinyin
from deepin.runner.runner import AppRunner, BottleConfig \
        , logging, Paths, run_desktop_entry, Lnk

@RunAsync.run_async
def _run_entry(config: BottleConfig, lnk: Lnk):
    run_desktop_entry(config, lnk, None)

def _random_str(length: int = 10) -> str:
    return "".join(
        random.choices(string.ascii_letters + string.digits, k=length)
    )

def _find_window_in_bottles(
        bottles_path: str,
        timeout: int = 10 # 10s timeout
    ) -> bool:
    '''
    Find a window in bottles, if found, return True. return false if timeout
    '''
    wait_win_proc = subprocess.Popen([
        "/opt/apps/deepin-wine-builder/files/bin/waitx11window",
        bottles_path,
        str(timeout)
    ])
    wait_win_proc.communicate()

    # found a window, so it's ok
    return wait_win_proc.returncode == 0

class InstallerToDebConverter:
    '''
    Convert a exe installer uri string to deb package.
    '''

    def __init__(self, uri: str, test_only: bool = False) -> None:
        self._uri: str = uri
        self._localfile_path: pathlib.Path = pathlib.Path()
        self._test_only: bool = test_only
        self._installer_args: str = ""
        self.installer_info: Optional[PEFileVersionInfo] = None
        self.runner = Paths.default_runner
        self._support_silent = False

        self._bottle_path = pathlib.Path(
            f"~/.cache/deepin-wine-builder/bottles/bottle_tests_{_random_str()}"
        ).expanduser()

        self._lnks_to_package: List[Lnk] = []

        self._ymlfile: str = ""
        self.save_path: str = pathlib.Path("./").absolute().as_posix()

        self.pkgname: str = ""
        self.pkgver: str = ""

    def _check_installer(self) -> Result[None]:
        '''
        Check if the uri is a valid exe installer uri string. download and fetch
        its file information in nessessary.
        '''

        if re.compile(r'^https?://').match(self._uri) is not None:
            self._uri = re.sub(r'/+$', '', self._uri)
            filename = self._uri.split('/')[-1]
            if not ComponentDownloader().download(self._uri, filename):
                return Result(False, message=f"下载失败")
            self._localfile_path = pathlib.Path(Paths().temp, filename)
        elif re.compile(r'^file://').match(self._uri) is not None:
            self._localfile_path = pathlib.Path(re.sub(r'^file://', '', self._uri))
        else:
            self._localfile_path = pathlib.Path(self._uri)

        if not self._localfile_path.exists():
            return Result(False, message=f"安装程序不存在")

        path = self._localfile_path.as_posix()
        try:
            self.installer_info = InstallerUtils.read_version_info(path)
        except Exception as _:
            pass
        args = InstallerUtils.get_silent_install_args(path)
        if args is None:
            return Result(False, message=f"不支持静默安装")
        self._installer_args = args

        self._support_silent = True

        logging.info(f"安装程序版本信息: {self.installer_info}")

        return Result(True)

    def _yml_template(
        self,
        package_name: str = "bottlestest",
        version: str = "1.0.0",
    ) -> str:
        '''
        Generate a yml template for bottles.
        '''
        return "\n".join([
           f"Name: \"{package_name}\"",
            "Dependencies: [ safefont, defaultfont ]",
            "DebInfo:",
            "  wine_cmd: {Paths.default_runner}",
            "  deb_arch: [ amd64 ]",
            "  categories: Graphics",
           f"  public_version: \"{version}\"",
            "Steps:",
            "- action: install_exe",
           f"  url: file://{self._localfile_path.as_posix()}",
           f"  file_name: {self._uri.split('/')[-1]}",
           f"  arguments: {self._installer_args}"
        ])

    @RunAsync.run_async
    def _check_installer_window(self, wait_event: Event, proc_to_kill: subprocess.Popen):
        '''
        If installer don't support silent arguments, and show popup window.
        Kill installer process and failed
        '''
        while True:
            # done
            if not pathlib.Path(f"/proc/{proc_to_kill.pid}").exists():
                break

            if _find_window_in_bottles(self._bottle_path.as_posix(), 2):
                self._support_silent = False
                bottles = BottleConfig(
                    Runner=self.runner,
                    Path=self._bottle_path.as_posix()
                )
                WineServer(bottles).kill()
                proc_to_kill.kill()
                break
        wait_event.set()

    def _install_installer(self) -> Result[None]:
        '''
        configure bottles and install the exe installer.
        '''
        tmpyml = self._ymlfile
        with open(tmpyml, 'w') as f:
            f.write(self._yml_template())

        proc = subprocess.Popen([
            "deepin-wine-bottles",
            "install",
            "program",
            "--yml",
            tmpyml,
            self._bottle_path.as_posix()
        ])
        wait_event: Event = Event()
        wait_event.clear()
        self._check_installer_window(wait_event, proc)
        proc.communicate()

        wait_event.wait()

        if not self._support_silent:
            return Result(False, message=f"不支持静默安装")

        if proc.returncode != 0:
            return Result(False, message=f"安装失败")
        return Result(True)

    def _check_entries(self) -> Result[None]:
        '''
        List entries in bottles, and check whether the entry can launch a
        window, if none of entries can launch a window, failed.
        '''
        bottles = BottleConfig(
            Runner=self.runner,
            Path=self._bottle_path.as_posix()
        )

        all_lnks = AppRunner.list_lnks(bottles)
        self._lnks_to_package = []

        for lnk in all_lnks:
            logging.info(f"检查 {lnk['name']} 是否可以运行...")

            # run entry via wine in background thread
            _run_entry(bottles, lnk)

            # block and wait x11 window of app created
            found = _find_window_in_bottles(self._bottle_path.as_posix())

            # kill wineserver / app if wait process exit
            WineServer(bottles).kill()

            # found a window, so it's ok
            if found:
                self._lnks_to_package.append(lnk)

        if len(self._lnks_to_package) == 0:
            names = [ x["name"] for x in all_lnks ]
            return Result(
                False,
                message=f"没有界面弹出，快捷方式列表: {names}"
            )

        return Result(True)

    def success_msg(self) -> str:
        names = [ x["name"] for x in self._lnks_to_package ]
        return f"{self._uri} => 包名 {self.pkgname}, 版本 {self.pkgver}, 快捷方式 {names}"

    def _generate_pkgname(self) -> Tuple[str, str]:
        '''
        generate package name and version from bottles
        '''
        bottles = BottleConfig(
            Runner=self.runner,
            Path=self._bottle_path.as_posix()
        )

        lnks = self._lnks_to_package[0]

        pe_info = {}
        try:
            pe_info = InstallerUtils.read_version_info(
                WinePath(bottles).to_unix(lnks["path"])
            )
        except Exception as e:
            pass

        pkgver: Optional[str] = None
        pkgname: Optional[str] = None

        for info in [ pe_info, self.installer_info ]:
            if info is None:
                continue
            pkgver = pkgver or info.get("FileVersion") \
                            or info.get("ProductVersion")
            pkgname = pkgname or info.get("ProductName") \
                              or info.get("InternalName")

        pkgname = pkgname or lnks["name"]

        def _replace(r: str) -> str:
            r = re.sub(r"[,，]", '', r)
            r = re.sub(r'[^a-zA-Z0-9\.]', '', r)
            return r

        if pkgver:
            pkgver = _replace(pkgver)

        if pkgname:
            pkgname = _replace(pinyin(pkgname)).lower()
            pkgname = f"com.{pkgname}.wineapp"

        # failback
        pkgname = pkgname \
            or f"com.{self._uri.split('/')[-1]}.{_random_str()}.wineapp"
        pkgver = pkgver or "1.0.0deepin0"

        return (pkgname, pkgver)

    def _generate_deb(self) -> Result[None]:
        '''
        Auto generate package name and version, then create deb package for wine app
        '''

        pkgname, pkgver = self._generate_pkgname()
        self.pkgname = pkgname
        self.pkgver = pkgver

        logging.info(f"pkgname: {pkgname}, pkgver:{pkgver}")

        # test only, no need to generate deb package
        if self._test_only:
            return Result(True)

        with open(self._ymlfile, "w") as f:
            f.write(self._yml_template(pkgname, pkgver))

        proc = subprocess.Popen([
            "deepin-wine-bottles",
            "make",
            "deb",
            "--skip-installer",
            "--entries"
        ] + [ lnk["name"] for lnk in self._lnks_to_package ]
          + [
            "--bottles-path",
            self._bottle_path.as_posix(),
            "--save-path",
            self.save_path,
            self._ymlfile
        ])
        proc.communicate()

        if proc.returncode != 0:
            return Result(False, message=f"生成 deb 时失败")

        return Result(True)

    def convert(self) -> Result[None]:
        '''
        Convert a exe installer uri string to deb package.
        '''
        steps = [
            self._check_installer,
            self._install_installer,
            self._check_entries,
            self._generate_deb,
        ]

        res: Result[None] = Result(True)
        _, self._ymlfile = tempfile.mkstemp()

        for step in steps:
            result = step()
            if not result.ok:
                res = result
                break

        if self._bottle_path.is_dir():
            shutil.rmtree(self._bottle_path, ignore_errors=True)

        if pathlib.Path(self._ymlfile).exists():
            os.remove(self._ymlfile)

        if re.compile(r'^https?://').match(self._uri) is not None \
            and self._localfile_path.is_file():
            os.remove(self._localfile_path)

        return res

def _append_log(log_file: str, line: str):
    with open(log_file, "a", encoding="utf-8") as f:
        f.writelines([line, "\n"])

class BatchConverter(metaclass=Singleton):
    '''
    Auto convert a list of exe files to deb package. You can pass a directory
    that contains exe installers or a uri list file.

    ```bash
    # pass a directory that contains exe installers
    ./tools/batch_convert.py /path/to/directory
    # pass a uri list
    ./tools/batch_convert.py /path/to/uri_list.txt
    ```
    '''

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.root_dir = pkgdatadir

        # installer uri list to be convert
        self.urilist: List[str] = []
        self.test_only: bool = False

        self.pending_list: List[str] = []
        self.failed_list: List[str] = []
        self.success_list: List[str] = []

    def start_convert(self):
        self.pending_list = self.pending_list or self.urilist.copy()
        logging.info(f"开始转换 {len(self.pending_list)} 个安装包")

        while len(self.pending_list) > 0:
            uri = self.pending_list[0]
            logging.info(f"开始转换 {uri}, 剩余 {len(self.pending_list)} 个 uri")

            converter = InstallerToDebConverter(uri, test_only=self.test_only)
            convert_res = converter.convert()
            if not convert_res.ok:
                logging.error(f"转换 {uri} 失败: {convert_res.message}")
                _append_log("failed.log", f"{uri}: {convert_res.message} {converter.installer_info or ''}")
                self.failed_list.append(uri)
            else:
                _append_log("success.log", converter.success_msg())
                self.success_list.append(uri)

            self.pending_list.remove(uri)

    def _init_opts(self):
        self._parser = argparse.ArgumentParser(
            description="Convert exe installers to deb packages."
        )

        self._parser.add_argument(
            "<dir|txt_file>",
            help="path to exe installers dir or uri list file",
        )
        self._parser.add_argument(
            "--test-only",
            help="test only, not convert",
            action="store_true"
        )

    def _parse_opts(self):
        if self.restore_status():
            logging.info("继续上次打包任务")
            self.start_convert()
            return

        args = self._parser.parse_args()

        path = pathlib.Path(getattr(args, "<dir|txt_file>"))
        if path.is_dir():
            self.urilist = [
                str(f.absolute()) \
                    for f in path.iterdir() if f.is_file()
            ]
        elif path.is_file():
            with open(path, "r") as f:
                self.urilist = [
                    line.strip() \
                        for line in f.readlines() if len(line.strip()) != 0
                ]
        else:
            logging.error(f"{path} 不是有效的文件夹，或者安装下载地址列表txt文件")
            sys.exit(1)

        self.test_only = args.test_only
        self.start_convert()

    def run(self) -> None:
        self._init_opts()
        self._parse_opts()

    def store_status(self, status_json_path: str = "status.json"):
        """
        Store current status to a json file.
        """
        if len(self.pending_list) > 0:
            with open(status_json_path, "w") as f:
                obj = {
                    "pending_list": self.pending_list,
                    "failed_list": self.failed_list,
                    "success_list": self.success_list
                }
                json.dump(obj, f, ensure_ascii=False, indent=4)
        else:
            if pathlib.Path(status_json_path).is_file():
                os.unlink(status_json_path)

    def restore_status(self, status_json_path: str = "status.json") -> bool:
        """
        Restore status from a json file.
        """
        if pathlib.Path(status_json_path).is_file():
            with open(status_json_path, "r") as f:
                obj = json.load(f)
                if isinstance(obj, dict):
                    self.pending_list = obj.get("pending_list", [])
                    self.failed_list = obj.get("failed_list", [])
                    self.success_list = obj.get("success_list", [])
                    return True
        return False

if __name__ == "__main__":
    converter = BatchConverter()
    try:
        converter.run()
    except BaseException as e:
        converter.store_status()
        raise e
