#!/usr/bin/python3
import json
import os
import re
import shutil
import struct
import subprocess
import sys
import tempfile
from pathlib import Path

TOOLS = Path(os.environ.get("DROIDDECK_MSITOOLS", "/usr/local/lib/droiddeck-msitools"))
MSIINFO = os.environ.get("DROIDDECK_MSIINFO", str(TOOLS / "msiinfo"))
CABEXTRACT = os.environ.get("DROIDDECK_CABEXTRACT", str(TOOLS / "cabextract"))
SEVENZIP = os.environ.get("DROIDDECK_7Z", str(TOOLS / "7z"))
MSI_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
BURN_MAGIC = 0x00F14300
SKIP_PLATFORMS = ("arm64", "arm")


def tool_env():
    env = dict(os.environ)
    env["LD_LIBRARY_PATH"] = ":".join(p for p in (str(TOOLS), env.get("LD_LIBRARY_PATH", "")) if p)
    return env

SKIP = None
KNOWN_FOLDERS = {
    "TARGETDIR": "C:\\", "ROOTDRIVE": "C:\\", "WindowsVolume": "C:\\",
    "WindowsFolder": "C:\\windows\\",
    "SystemFolder": "C:\\windows\\syswow64\\",
    "System64Folder": "C:\\windows\\system32\\",
    "System16Folder": "C:\\windows\\system\\",
    "FontsFolder": "C:\\windows\\Fonts\\",
    "ProgramFilesFolder": "C:\\Program Files (x86)\\",
    "ProgramFiles64Folder": "C:\\Program Files\\",
    "CommonFilesFolder": "C:\\Program Files (x86)\\Common Files\\",
    "CommonFiles64Folder": "C:\\Program Files\\Common Files\\",
    "CommonAppDataFolder": "C:\\ProgramData\\",
    "AppDataFolder": "C:\\users\\steamuser\\AppData\\Roaming\\",
    "LocalAppDataFolder": "C:\\users\\steamuser\\AppData\\Local\\",
    "PersonalFolder": "C:\\users\\steamuser\\Documents\\",
}
for _name in ("TempFolder", "DesktopFolder", "ProgramMenuFolder", "StartMenuFolder", "StartupFolder",
              "SendToFolder", "FavoritesFolder", "NetHoodFolder", "PrintHoodFolder", "RecentFolder",
              "TemplateFolder", "MyPicturesFolder", "AdminToolsFolder"):
    KNOWN_FOLDERS[_name] = SKIP

SYSTEM_PROPERTIES = {
    "VersionNT": "603", "VersionNT64": "603", "WindowsBuild": "9600", "ServicePackLevel": "0",
    "Msix64": "6", "ALLUSERS": "1", "Privileged": "1", "AdminUser": "1", "MsiNTProductType": "1",
    "UILevel": "2", "INSTALLLEVEL": "1", "ProgramFilesFolder": None, "VersionMsi": "5.00",
}

COMPONENT_64BIT = 0x100
FILE_NONCOMPRESSED = 0x2000
SET_PROPERTY, SET_DIRECTORY, ERROR_ACTION = 51, 35, 19
SFP_CACHE_ACTION = "CDirSystemSFPCacheDir"
HANDLED_ACTIONS = ("Wdsfpca_", "SxsInstallCA", "SxsUninstallCA")
UNSUPPORTED_TABLES = ("SelfReg", "Class", "ProgId", "TypeLib", "Extension", "Verb", "MIME", "AppId",
                      "IniFile", "ServiceInstall", "MoveFile", "IsolatedComponent", "PublishComponent",
                      "ODBCDriver", "ODBCDataSource")


class MsiError(Exception):
    pass


def progress(text, fraction=None):
    if fraction is None:
        print("progress " + text, flush=True)
    else:
        print("progress %d%% %s" % (max(0, min(100, int(fraction * 100))), text), flush=True)


class Span:
    def __init__(self, lo=0.0, hi=1.0):
        self.lo, self.hi = lo, hi

    def report(self, fraction, text):
        progress(text, self.lo + (self.hi - self.lo) * max(0.0, min(1.0, fraction)))



IDT_ESCAPES = str.maketrans({"\x19": "\n", "\x11": "\r", "\x15": "\t", "\x1b": ";"})


def msiinfo(*args, binary=False):
    try:
        result = subprocess.run([MSIINFO, *args], capture_output=True, timeout=600, env=tool_env())
    except OSError as error:
        raise MsiError("msitools is not available (%s)" % error)
    if result.returncode != 0:
        raise MsiError("msiinfo %s: %s" % (args[0], result.stderr.decode("utf-8", "replace").strip()[:300]))
    return result.stdout if binary else result.stdout.decode("utf-8", "replace")


class Package:
    def __init__(self, path):
        self.path = str(path)
        self.table_names = set(msiinfo("tables", self.path).split())
        self._tables = {}
        self.summary = {}
        for line in msiinfo("suminfo", self.path).splitlines():
            name, sep, value = line.partition(":")
            if sep:
                self.summary[name.strip().lower()] = value.strip()

    def table(self, name):
        if name in self._tables:
            return self._tables[name]
        rows = []
        if name in self.table_names:
            lines = [line.rstrip("\r") for line in msiinfo("export", self.path, name).split("\n")]
            columns = lines[0].split("\t") if lines else []
            for line in lines[3:]:
                if not line:
                    continue
                cells = [cell.translate(IDT_ESCAPES) for cell in line.split("\t")]
                cells += [""] * (len(columns) - len(cells))
                rows.append(dict(zip(columns, cells)))
        self._tables[name] = rows
        return rows

    def stream(self, name, target):
        Path(target).write_bytes(msiinfo("extract", self.path, name, binary=True))


def long_name(value):
    return value.split("|")[-1]


def to_int(value, default=0):
    try:
        return int(str(value).strip())
    except ValueError:
        return default



TOKEN = re.compile(r'\s*(?:(?P<str>"[^"]*")|(?P<num>-?\d+)(?![\w.])|(?P<op>~?(?:<>|<=|>=|><|<<|>>|=|<|>))'
                   r'|(?P<paren>[()])|(?P<word>[%$?&!]?[A-Za-z_][\w.]*))')
KEYWORDS = {"not", "and", "or", "xor", "eqv", "imp"}


class Conditions:

    def __init__(self, props, feature_state=None, component_state=None):
        self.props = props
        self.feature_state = feature_state or (lambda name: None)
        self.component_state = component_state or (lambda name: None)

    def evaluate(self, text, default=True):
        text = (text or "").strip()
        if not text:
            return True
        try:
            self.tokens = self.tokenize(text)
            self.pos = 0
            value = self.expression()
            if self.pos != len(self.tokens):
                return default
            return self.truth(value)
        except (ValueError, IndexError):
            return default

    def tokenize(self, text):
        tokens, pos = [], 0
        while pos < len(text):
            if text[pos:].strip() == "":
                break
            match = TOKEN.match(text, pos)
            if not match or match.end() == pos:
                raise ValueError(text)
            pos = match.end()
            kind = match.lastgroup
            value = match.group(kind)
            if kind == "word" and value.lower() in KEYWORDS:
                tokens.append(("kw", value.lower()))
            else:
                tokens.append((kind, value))
        return tokens

    def peek(self):
        return self.tokens[self.pos] if self.pos < len(self.tokens) else (None, None)

    def take(self, kind, value=None):
        token = self.peek()
        if token[0] == kind and (value is None or token[1] == value):
            self.pos += 1
            return True
        return False

    def expression(self):
        value = self.binary(0)
        return value

    LEVELS = ("imp", "eqv", "xor", "or", "and")

    def binary(self, level):
        if level == len(self.LEVELS):
            return self.negation()
        left = self.binary(level + 1)
        op = self.LEVELS[level]
        while self.take("kw", op):
            right = self.truth(self.binary(level + 1))
            a = self.truth(left)
            left = {"and": a and right, "or": a or right, "xor": a != right,
                    "eqv": a == right, "imp": (not a) or right}[op]
        return left

    def negation(self):
        if self.take("kw", "not"):
            return not self.truth(self.negation())
        return self.comparison()

    def comparison(self):
        left = self.operand()
        kind, op = self.peek()
        if kind != "op":
            return left
        self.pos += 1
        right = self.operand()
        return self.compare(left, op, right)

    def operand(self):
        kind, value = self.peek()
        self.pos += 1
        if kind == "paren" and value == "(":
            inner = self.expression()
            if not self.take("paren", ")"):
                raise ValueError("unbalanced")
            return inner
        if kind == "str":
            return value[1:-1]
        if kind == "num":
            return int(value)
        if kind == "word":
            sigil, name = (value[0], value[1:]) if value[0] in "%$?&!" else ("", value)
            if sigil == "%":
                return os.environ.get(name, "")
            if sigil in ("&", "!"):
                state = self.feature_state(name)
                return state if state is not None else -1
            if sigil in ("$", "?"):
                state = self.component_state(name)
                return state if state is not None else -1
            found = self.props.get(name)
            return "" if found is None else found
        raise ValueError("operand expected")

    @staticmethod
    def number(value):
        if isinstance(value, bool):
            return int(value)
        if isinstance(value, int):
            return value
        if isinstance(value, str) and re.fullmatch(r"-?\d+", value.strip()):
            return int(value)
        return None

    def compare(self, left, op, right):
        fold = op.startswith("~")
        op = op.lstrip("~")
        a, b = self.number(left), self.number(right)
        if a is not None and b is not None:
            return {"=": a == b, "<>": a != b, "<": a < b, ">": a > b, "<=": a <= b, ">=": a >= b,
                    "><": (a & b) != 0, "<<": (a >> 16) == b, ">>": (a & 0xFFFF) == b}[op]
        if isinstance(left, (int, bool)) or isinstance(right, (int, bool)):
            return op == "<>"
        a, b = str(left), str(right)
        if fold:
            a, b = a.lower(), b.lower()
        return {"=": a == b, "<>": a != b, "<": a < b, ">": a > b, "<=": a <= b, ">=": a >= b,
                "><": b in a, "<<": a.startswith(b), ">>": a.endswith(b)}[op]

    @staticmethod
    def truth(value):
        if isinstance(value, bool):
            return value
        if isinstance(value, int):
            return value != 0
        return value not in ("", None)



def win_to_rel(path):
    if not path:
        return None
    match = re.match(r"^[Cc]:\\?(.*)$", path)
    if not match:
        return None
    rest = [part for part in match.group(1).replace("/", "\\").split("\\") if part not in ("", ".")]
    if any(part == ".." for part in rest):
        return None
    return "/".join(["drive_c"] + rest)


def rel_to_win(rel, folder=True):
    parts = rel.split("/")[1:]
    return "C:\\" + "\\".join(parts) + ("\\" if folder and parts else "")


def clean_version(version):
    parts = [str(to_int(p)) for p in version.split(".") if p != ""]
    return ".".join(parts) if parts else "0.0.0.0"


def clr_version(path):
    try:
        data = Path(path).read_bytes()
    except OSError:
        return ""
    at = data.find(b"BSJB")
    if at < 0 or at + 16 > len(data):
        return ""
    length = int.from_bytes(data[at + 12:at + 16], "little")
    return data[at + 16:at + 16 + min(length, 32)].split(b"\0")[0].decode("ascii", "replace")


class Installer:
    def __init__(self, msi, out, package=None, span=None):
        self.pkg = package or Package(msi)
        self.out = Path(out)
        self.span = span or Span()
        self.notes = []
        self.registry = []
        self.files = {}
        self.counts = {"files": 0, "assemblies": 0, "registry": 0}
        template = self.pkg.summary.get("template", "")
        self.platform = template.split(";")[0].strip().lower()
        self.props = {k: v for k, v in SYSTEM_PROPERTIES.items() if v is not None}
        if self.platform in ("", "intel"):
            self.props["Intel"] = "6"
        for name, path in KNOWN_FOLDERS.items():
            self.props[name] = path if path is not None else "\0skip"
        for row in self.pkg.table("Property"):
            if row["Property"] not in self.props or row["Property"] in ("INSTALLLEVEL", "ALLUSERS", "UILevel"):
                self.props[row["Property"]] = row["Value"]
        self.props["ALLUSERS"] = "1"
        self.directories = {row["Directory"]: row for row in self.pkg.table("Directory")}
        self.components = {row["Component"]: row for row in self.pkg.table("Component")}
        self.included_features = set()
        self.included_components = set()
        self.conditions = Conditions(self.props, self.feature_state, self.component_state)
        self._resolving = set()
        self._set_by_action = set()


    def feature_state(self, name):
        return 3 if name in self.included_features else -1

    def component_state(self, name):
        return 3 if name in self.included_components else -1

    def directory(self, key):
        if key in self._resolving:
            return None
        value = self.props.get(key)
        row = self.directories.get(key)
        if key in KNOWN_FOLDERS or key in self._set_by_action or row is None:
            return None if value in (None, "", "\0skip") else win_to_rel(value)
        parent_key = row.get("Directory_Parent", "")
        default = long_name(row.get("DefaultDir", "").split(":")[0])
        if not parent_key or parent_key == key:
            return "drive_c"
        self._resolving.add(key)
        try:
            parent = self.directory(parent_key)
        finally:
            self._resolving.discard(key)
        if parent is None:
            return None
        if default in (".", ""):
            return parent
        if "\\" in default or "/" in default or default == "..":
            return None
        return parent + "/" + default

    def format(self, text):
        if not text:
            return text

        def one(match):
            inner = match.group(1)
            if inner.startswith("\\") and len(inner) == 2:
                return inner[1]
            if inner == "~":
                return "\0"
            if inner.startswith("#") or inner.startswith("!"):
                rel = self.files.get(inner[1:])
                return rel_to_win(rel, folder=False) if rel else ""
            if inner.startswith("$"):
                comp = self.components.get(inner[1:])
                rel = self.directory(comp["Directory_"]) if comp else None
                return rel_to_win(rel) if rel else ""
            if inner.startswith("%"):
                return os.environ.get(inner[1:], "")
            if inner in self.directories or inner in KNOWN_FOLDERS:
                rel = self.directory(inner)
                return rel_to_win(rel) if rel else ""
            value = self.props.get(inner, "")
            return "" if value == "\0skip" else value

        previous = None
        for _ in range(8):
            if text == previous:
                break
            previous = text
            text = re.sub(r"\[([^\[\]]*)\]", one, text)
        return text


    def run_set_actions(self):
        actions = {row["Action"]: row for row in self.pkg.table("CustomAction")}
        sequence = {}
        for table in ("InstallUISequence", "InstallExecuteSequence"):
            for row in self.pkg.table(table):
                if row["Action"] in actions and row["Action"] not in sequence:
                    sequence[row["Action"]] = (to_int(row.get("Sequence"), 0), row.get("Condition", ""))
        skipped = []
        sfp = "_SfpCaInf" in self.pkg.table_names
        for name, (_, condition) in sorted(sequence.items(), key=lambda item: item[1][0]):
            row = actions[name]
            kind = to_int(row["Type"]) & 0x3F
            if name.startswith(HANDLED_ACTIONS) or (sfp and name.startswith(SFP_CACHE_ACTION)):
                continue
            if kind in (SET_PROPERTY, SET_DIRECTORY):
                if not self.conditions.evaluate(condition, default=True):
                    continue
                value = self.format(row.get("Target", ""))
                self.props[row["Source"]] = value
                self._set_by_action.add(row["Source"])
            elif kind != ERROR_ACTION and self.conditions.evaluate(condition, default=True):
                skipped.append(name)
        if skipped:
            self.notes.append("setup actions not run (they need the installer's own code): " + ", ".join(skipped[:8])
                              + (" and %d more" % (len(skipped) - 8) if len(skipped) > 8 else ""))

    def select(self):
        install_level = to_int(self.props.get("INSTALLLEVEL"), 1) or 1
        features = {row["Feature"]: dict(row) for row in self.pkg.table("Feature")}
        for row in self.pkg.table("Condition"):
            feature = features.get(row["Feature_"])
            if feature is not None and self.conditions.evaluate(row["Condition"], default=False):
                feature["Level"] = row["Level"]

        def included(name, depth=0):
            feature = features.get(name)
            if feature is None or depth > 32:
                return False
            level = to_int(feature.get("Level"), 0)
            if level <= 0 or level > install_level:
                return False
            parent = feature.get("Feature_Parent", "")
            return not parent or parent == name or included(parent, depth + 1)

        self.included_features = {name for name in features if included(name)}
        mapped = {}
        for row in self.pkg.table("FeatureComponents"):
            mapped.setdefault(row["Component_"], set()).add(row["Feature_"])
        for name, row in self.components.items():
            owners = mapped.get(name)
            if owners is not None and not owners & self.included_features:
                continue
            if not self.conditions.evaluate(row.get("Condition", ""), default=True):
                continue
            self.included_components.add(name)

    def assemblies(self):
        names = {}
        for row in self.pkg.table("MsiAssemblyName"):
            names.setdefault(row["Component_"], {})[row["Name"].lower()] = row["Value"]
        placed = {}
        for row in self.pkg.table("MsiAssembly"):
            comp = row["Component_"]
            if comp not in self.included_components or row.get("File_Application"):
                continue
            ident = names.get(comp, {})
            placed[comp] = (to_int(row.get("Attributes"), 0), ident, row.get("File_Manifest", ""))
        return placed

    def gac_folder(self, ident, assembly_file):
        name = ident.get("name", "")
        token = ident.get("publickeytoken", "").lower()
        if not name or not token or "/" in name or "\\" in name:
            return None
        version = clean_version(ident.get("version", "0.0.0.0"))
        culture = ident.get("culture", "neutral")
        culture = "" if culture.lower() == "neutral" else culture
        arch = ident.get("processorarchitecture", "").lower()
        gac = {"x86": "GAC_32", "amd64": "GAC_64", "x64": "GAC_64", "msil": "GAC_MSIL"}.get(arch, "GAC_MSIL" if arch else "GAC")
        if clr_version(assembly_file).startswith("v4"):
            return "drive_c/windows/Microsoft.NET/assembly/%s/%s/v4.0_%s_%s_%s" % (gac, name, version, culture, token)
        return "drive_c/windows/assembly/%s/%s/%s_%s_%s" % (gac, name, version, culture, token)

    @staticmethod
    def sxs_names(ident):
        arch = ident.get("processorarchitecture", "x86").lower() or "x86"
        name = ident.get("name", "").lower()
        token = ident.get("publickeytoken", "").lower()
        version = ident.get("version", "")
        if not name or not token or not version or "/" in name or "\\" in name:
            return None
        if ident.get("type", "").lower() == "win32-policy":
            target = re.sub(r"^policy\.\d+\.\d+\.", "", name)
            return "policy", "drive_c/windows/winsxs/policies/%s_%s_%s_none_deadbeef" % (arch, target, token), version
        return "assembly", "%s_%s_%s_%s_none_deadbeef" % (arch, name, token, version), version

    def unpack(self, work):
        found = {}
        media = [row for row in self.pkg.table("Media") if row.get("Cabinet", "")]
        for index, row in enumerate(media):
            cabinet = row.get("Cabinet", "")
            self.span.report(0.05 + 0.3 * index / max(1, len(media)), "unpacking %s" % cabinet.lstrip("#"))
            dest = Path(work) / ("media%d" % index)
            dest.mkdir()
            if cabinet.startswith("#"):
                cab = Path(work) / ("media%d.cab" % index)
                self.pkg.stream(cabinet[1:], cab)
                own = True
            else:
                cab = sibling(Path(self.pkg.path), cabinet)
                if cab is None:
                    self.notes.append("external cabinet %s is not part of the package; its files are missing" % cabinet)
                    continue
                own = False
            result = subprocess.run([CABEXTRACT, "-q", "-d", str(dest), str(cab)], capture_output=True, timeout=1800,
                                    env=tool_env())
            if result.returncode != 0:
                raise MsiError("cabextract: %s" % result.stderr.decode("utf-8", "replace").strip()[:300])
            if own:
                cab.unlink()
            for entry in dest.rglob("*"):
                if entry.is_file():
                    found.setdefault(entry.relative_to(dest).as_posix(), entry)
        return found

    def put(self, source, rel):
        target = self.out / rel
        if target.exists() and Path(source).samefile(target):
            return False
        target.parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(source, target)
        return True

    def install_files(self, unpacked):
        assemblies = self.assemblies()
        files = self.pkg.table("File")
        missing = 0
        destinations = {}
        for row in files:
            comp = row["Component_"]
            if comp not in self.included_components:
                continue
            name = long_name(row["FileName"])
            if not name or "/" in name or "\\" in name or name in (".", ".."):
                continue
            if comp in assemblies:
                kind, ident, manifest = assemblies[comp]
                if kind == 0:
                    source = unpacked.get(self.components[comp].get("KeyPath", ""))
                    folder = self.gac_folder(ident, source) if source else None
                    destinations[row["File"]] = folder + "/" + name if folder else None
                else:
                    names = self.sxs_names(ident)
                    if names is None:
                        destinations[row["File"]] = None
                    elif names[0] == "policy":
                        destinations[row["File"]] = (names[1] + "/" + names[2] + ".policy") if row["File"] == manifest else None
                    elif row["File"] == manifest:
                        destinations[row["File"]] = "drive_c/windows/winsxs/manifests/%s.manifest" % names[1]
                    elif name.lower().endswith(".cat"):
                        destinations[row["File"]] = None
                    else:
                        destinations[row["File"]] = "drive_c/windows/winsxs/%s/%s" % (names[1], name)
                continue
            folder = self.directory(self.components[comp]["Directory_"])
            destinations[row["File"]] = folder + "/" + name if folder else None
        for key, rel in destinations.items():
            if rel:
                self.files[key] = rel
        assembly_folders = set()
        todo = [(key, rel) for key, rel in destinations.items() if rel]
        last = -1
        for done, (key, rel) in enumerate(todo):
            step = done * 20 // max(1, len(todo))
            if step != last:
                last = step
                self.span.report(0.35 + 0.55 * done / max(1, len(todo)), "placing files (%d of %d)" % (done, len(todo)))
            source = unpacked.get(key)
            if source is None:
                missing += 1
                continue
            self.put(source, rel)
            self.counts["files"] += 1
            if "/assembly/" in rel or "/winsxs/" in rel:
                assembly_folders.add(rel.rsplit("/", 1)[0])
        self.counts["assemblies"] = len(assembly_folders)
        for row in self.pkg.table("DuplicateFile"):
            if row["Component_"] not in self.included_components or row["File_"] not in self.files:
                continue
            folder = self.directory(row["DestFolder"]) if row.get("DestFolder") else self.files[row["File_"]].rsplit("/", 1)[0]
            name = long_name(row.get("DestName", "")) or self.files[row["File_"]].rsplit("/", 1)[1]
            source = self.out / self.files[row["File_"]]
            if folder and source.is_file() and "/" not in name and self.put(source, folder + "/" + name):
                self.counts["files"] += 1
        if missing:
            self.notes.append("%d file(s) were not in the package's cabinets" % missing)

    def value(self, hive, key, name, kind, data, wow64):
        lower = key.lower()
        if hive == "HKLM" and wow64 and lower.startswith("software\\") and not lower.startswith("software\\wow6432node\\"):
            key = "Software\\Wow6432Node\\" + key[len("software\\"):]
        self.registry.append({"hive": hive, "key": key, "name": name, "type": kind, "data": data})

    def registry_data(self, raw):
        if raw is None or raw == "":
            return "sz", ""
        if raw.startswith("#x") or raw.startswith("#X"):
            hexdigits = re.sub(r"[^0-9A-Fa-f]", "", self.format(raw[2:]))
            if len(hexdigits) % 2:
                hexdigits = "0" + hexdigits
            return "binary", hexdigits.lower()
        if raw.startswith("#%"):
            return "expand_sz", self.format(raw[2:])
        if raw.startswith("##"):
            return "sz", self.format(raw[1:])
        if raw.startswith("#"):
            number = self.format(raw[1:])
            if re.fullmatch(r"[+-]?\d+", number.strip()):
                return "dword", int(number) & 0xFFFFFFFF
            return "sz", number
        text = self.format(raw)
        if "\0" in text:
            parts = [part for part in text.split("\0") if part]
            return "multi_sz", parts
        return "sz", text

    def install_registry(self):
        for row in self.pkg.table("Registry"):
            comp = row["Component_"]
            if comp not in self.included_components:
                continue
            root = to_int(row["Root"], 2)
            hive, prefix = {-1: ("HKLM", ""), 0: ("HKLM", "Software\\Classes\\"), 1: ("HKCU", ""),
                            2: ("HKLM", "")}.get(root, (None, None))
            if hive is None:
                continue
            key = prefix + self.format(row["Key"]).strip("\\")
            wow64 = not (to_int(self.components[comp].get("Attributes"), 0) & COMPONENT_64BIT)
            name = row.get("Name", "")
            raw = row.get("Value", "")
            if name in ("+", "-", "*") and not raw:
                self.value(hive, key, None, "key", None, wow64)
                continue
            if not name and not raw:
                self.value(hive, key, None, "key", None, wow64)
                continue
            kind, data = self.registry_data(raw)
            self.value(hive, key, self.format(name) or "", kind, data, wow64)
            self.counts["registry"] += 1
        for row in self.pkg.table("Environment"):
            if row["Component_"] not in self.included_components:
                continue
            flags = re.match(r"^[=+\-!*]*", row["Name"]).group(0)
            name = row["Name"][len(flags):]
            if "!" in flags or not name:
                continue
            raw = row.get("Value", "") or ""
            hive, key = ("HKLM", "System\\CurrentControlSet\\Control\\Session Manager\\Environment") if "*" in flags \
                else ("HKCU", "Environment")
            mode = "set"
            if raw.startswith("[~]"):
                mode, raw = "append", raw[3:]
            elif raw.endswith("[~]"):
                mode, raw = "prepend", raw[:-3]
            separator = ";"
            if mode == "append" and raw[:1] in ";:":
                separator, raw = raw[0], raw[1:]
            elif mode == "prepend" and raw[-1:] in ";:":
                separator, raw = raw[-1], raw[:-1]
            value = self.format(raw)
            if value:
                self.registry.append({"hive": hive, "key": key, "name": name, "type": mode, "data": value, "separator": separator})
                self.counts["registry"] += 1
        for row in self.pkg.table("Font"):
            rel = self.files.get(row["File_"])
            if not rel:
                continue
            title = row.get("FontTitle") or Path(rel).stem + " (TrueType)"
            self.value("HKLM", "Software\\Microsoft\\Windows NT\\CurrentVersion\\Fonts", title, "sz",
                       rel.rsplit("/", 1)[1], False)
            self.counts["registry"] += 1

    def check(self):
        for row in self.pkg.table("LaunchCondition"):
            if not self.conditions.evaluate(row["Condition"], default=True):
                self.notes.append("the package says: " + self.format(row.get("Description", "")).strip())
        for name in UNSUPPORTED_TABLES:
            count = len(self.pkg.table(name)) if name in self.pkg.table_names else 0
            if count:
                what = "self-registering DLL(s) not registered" if name == "SelfReg" else "%s entries not written" % name
                self.notes.append("%d %s" % (count, what))

    def install(self):
        self.span.report(0.0, "reading %s" % (self.pkg.summary.get("subject") or Path(self.pkg.path).name))
        self.run_set_actions()
        self.select()
        with tempfile.TemporaryDirectory(dir=str(self.out.parent), prefix=".msi-") as work:
            unpacked = self.unpack(work)
            self.span.report(0.35, "placing files")
            self.install_files(unpacked)
        self.span.report(0.9, "writing the registry values")
        self.install_registry()
        self.check()
        if not self.counts["files"] and not self.registry:
            raise MsiError("the package installs nothing here")
        values = []
        try:
            with open(self.out / "registry.json", encoding="utf-8") as source:
                values = json.load(source).get("values", [])
        except (FileNotFoundError, ValueError, AttributeError):
            pass
        with open(self.out / "registry.json", "w", encoding="utf-8") as out:
            json.dump({"version": 1, "values": values + self.registry}, out, indent=1)
        self.span.report(1.0, "done")
        return {"product": self.props.get("ProductName", ""), "version": self.props.get("ProductVersion", ""),
                "counts": self.counts, "notes": self.notes}



def sibling(path, name):
    folder = path.parent
    candidate = folder / name
    if candidate.is_file():
        return candidate
    lower = name.lower()
    for entry in folder.iterdir():
        if entry.is_file() and entry.name.lower() == lower:
            return entry
    return None


def kind_of(path):
    with open(path, "rb") as source:
        head = source.read(8)
    if head.startswith(MSI_MAGIC):
        return "msi"
    if head[:2] == b"MZ":
        return "exe"
    if head[:4] == b"MSCF":
        return "cab"
    return "other"


def run_tool(args, timeout=1800):
    try:
        return subprocess.run(args, capture_output=True, timeout=timeout, env=tool_env())
    except OSError as error:
        raise MsiError("%s is not available (%s)" % (Path(args[0]).name, error))


def burn_containers(data):
    try:
        pe = struct.unpack_from("<I", data, 0x3C)[0]
        if data[pe:pe + 4] != b"PE\0\0":
            return None
        count, optional = struct.unpack_from("<H", data, pe + 6)[0], struct.unpack_from("<H", data, pe + 20)[0]
        table = pe + 24 + optional
        section = None
        for i in range(count):
            name, _, _, raw_size, raw_offset = struct.unpack_from("<8sIIII", data, table + 40 * i)
            if name.rstrip(b"\0") == b".wixburn":
                section = (raw_offset, raw_size)
        if section is None or section[1] < 52:
            return None
        offset = section[0]
        if struct.unpack_from("<I", data, offset)[0] != BURN_MAGIC:
            return None
        stub, _, _, _, _, containers = struct.unpack_from("<IIIIII", data, offset + 24)
        sizes = struct.unpack_from("<%dI" % containers, data, offset + 48)
    except (struct.error, IndexError):
        return None
    found = []
    position = stub
    for size in sizes:
        while True:
            start = data.find(b"MSCF", position)
            if start < 0 or start + 12 > len(data):
                return None
            if struct.unpack_from("<I", data, start + 8)[0] == size:
                break
            position = start + 4
        found.append((start, size))
        position = start + size
    return found


def unpack_burn(path, dest, containers):
    data = path.read_bytes()
    names = {}
    for index, (start, size) in enumerate(containers):
        progress("unpacking the bundle, part %d of %d" % (index + 1, len(containers)), 0.02 + 0.08 * index / max(1, len(containers)))
        cab = dest / ("container%d.cab" % index)
        cab.write_bytes(data[start:start + size])
        folder = dest / ("container%d" % index)
        folder.mkdir()
        result = run_tool([CABEXTRACT, "-q", "-d", str(folder), str(cab)])
        cab.unlink()
        if result.returncode != 0:
            raise MsiError("cabextract: %s" % result.stderr.decode("utf-8", "replace").strip()[:300])
        if index == 0:
            manifest = folder / "0"
            if manifest.is_file():
                text = manifest.read_text("utf-8", "replace")
                for file_path, source in re.findall(r'<Payload\b[^>]*?\bFilePath="([^"]+)"[^>]*?\bSourcePath="([^"]+)"', text):
                    names[source] = file_path
            continue
        for entry in list(folder.iterdir()):
            wanted = names.get(entry.name)
            if not wanted:
                continue
            rel = Path(*[part for part in wanted.replace("\\", "/").split("/") if part not in ("", ".", "..")])
            target = folder / rel
            target.parent.mkdir(parents=True, exist_ok=True)
            entry.rename(target)


def unpack_installer(path, dest):
    kind = kind_of(path)
    if kind == "exe":
        containers = burn_containers(path.read_bytes())
        if containers:
            unpack_burn(path, dest, containers)
            return "bundle"
    if kind == "cab" or kind == "exe" and b"MSCF" in path.read_bytes():
        result = run_tool([CABEXTRACT, "-q", "-d", str(dest), str(path)])
        if result.returncode == 0 and any(dest.rglob("*")):
            return "cabinet"
        for stale in list(dest.iterdir()):
            shutil.rmtree(stale) if stale.is_dir() else stale.unlink()
    progress("unpacking %s" % path.name, 0.02)
    result = run_tool([SEVENZIP, "x", "-y", "-bd", "-bso0", "-bsp0", "-o" + str(dest), str(path)])
    if result.returncode not in (0, 1) or not any(dest.rglob("*")):
        raise MsiError("could not open %s: %s" % (path.name, (result.stderr or result.stdout).decode("utf-8", "replace").strip()[:200]
                                                   or "nothing inside it can be unpacked"))
    return "archive"


def packages_in(root):
    found = []
    for entry in sorted(root.rglob("*")):
        if entry.is_file() and kind_of(entry) == "msi":
            found.append(entry)
    found.sort(key=lambda entry: (len(entry.relative_to(root).parts), entry.name.lower()))
    return found


def choose_packages(found, root):
    if not found:
        return []
    depth = min(len(entry.relative_to(root).parts) for entry in found)
    bundle = (root / "container1").is_dir()
    top = [entry for entry in found if bundle or len(entry.relative_to(root).parts) == depth]
    names = [entry.name.lower() for entry in top]
    if any("full" in name for name in names) and any("_core" in name or "_extended" in name for name in names):
        top = [entry for entry in top if not ("_core" in entry.name.lower() or "_extended" in entry.name.lower())]
    chosen = []
    for entry in top:
        template = Package(entry).summary.get("template", "").split(";")[0].strip().lower()
        if template in SKIP_PLATFORMS:
            continue
        chosen.append(entry)
    return chosen


def install_wrapper(path, out):
    total = {"product": "", "version": "", "counts": {"files": 0, "assemblies": 0, "registry": 0}, "notes": [], "packages": []}
    with tempfile.TemporaryDirectory(dir=str(out.parent), prefix=".unpack-") as work:
        dest = Path(work) / "x"
        dest.mkdir()
        how = unpack_installer(path, dest)
        packages = choose_packages(packages_in(dest), dest)
        if not packages:
            raise MsiError("%s holds no installer package: it is its own setup program, which cannot be run here" % path.name)
        for index, package in enumerate(packages):
            lo, hi = 0.1 + 0.9 * index / len(packages), 0.1 + 0.9 * (index + 1) / len(packages)
            progress("package %d of %d: %s" % (index + 1, len(packages), package.name), lo)
            try:
                result = Installer(str(package), out, span=Span(lo, hi)).install()
            except MsiError as error:
                if "installs nothing" in str(error) and len(packages) > 1:
                    total["notes"].append("%s: %s" % (package.name, error))
                    continue
                raise
            total["packages"].append(package.name)
            if not total["product"]:
                total["product"], total["version"] = result["product"], result["version"]
            for key in total["counts"]:
                total["counts"][key] += result["counts"].get(key, 0)
            for note in result["notes"]:
                if note not in total["notes"]:
                    total["notes"].append(note)
        total["how"] = how
    return total


NESTED_SUFFIXES = (".7z", ".zip", ".cab", ".exe", ".msi", ".rar", ".xz", ".gz", ".tar")


def open_nested(dest, depth=0):
    if depth > 1:
        return 0
    opened = 0
    for path in sorted(p for p in dest.rglob("*") if p.is_file() and p.suffix.lower() in NESTED_SUFFIXES):
        if path.name.endswith(".nested") or path.stat().st_size < 1024 or ".nested/" in path.relative_to(dest).as_posix() and depth == 0 and False:
            continue
        probe = run_tool([SEVENZIP, "l", "-ba", str(path)], timeout=300)
        if probe.returncode != 0 or not probe.stdout.strip():
            continue
        folder = path.with_name(path.name + ".nested")
        folder.mkdir(exist_ok=True)
        run_tool([SEVENZIP, "x", "-y", "-bd", "-bso0", "-bsp0", "-o" + str(folder), str(path)])
        if any(folder.rglob("*")):
            opened += 1 + open_nested(folder, depth + 1)
        else:
            shutil.rmtree(folder, ignore_errors=True)
    return opened


def unpack_only(path, dest):
    dest.mkdir(parents=True, exist_ok=True)
    how = unpack_installer(path, dest)
    progress("opening archives inside %s" % path.name)
    nested = open_nested(dest)
    files = sorted(str(entry.relative_to(dest)) for entry in dest.rglob("*") if entry.is_file())
    return {"how": how, "files": len(files), "nested": nested, "names": files[:2000]}


def main():
    args = sys.argv[1:]
    if len(args) == 3 and args[0] == "--unpack":
        try:
            result = unpack_only(Path(args[1]), Path(args[2]))
        except MsiError as error:
            print("error " + str(error), flush=True)
            return 1
        print("result " + json.dumps(result), flush=True)
        return 0
    if len(args) != 2:
        print("usage: droiddeck-msi-install <package.msi | installer.exe> <component dir>\n"
              "       droiddeck-msi-install --unpack <archive> <dir>", file=sys.stderr)
        return 2
    source, out = Path(args[0]), Path(args[1])
    out.mkdir(parents=True, exist_ok=True)
    try:
        if kind_of(source) == "msi":
            result = Installer(str(source), out).install()
        else:
            result = install_wrapper(source, out)
    except MsiError as error:
        print("error " + str(error), flush=True)
        return 1
    print("result " + json.dumps(result), flush=True)
    return 0


if __name__ == "__main__":
    sys.exit(main())
