#!/usr/bin/python3
"""motel-compat-open — handle a double-clicked Windows installer.

Registered as the handler for the .exe and .msi MIME types. This is the whole
promise of the compatibility layer: double-click an installer, end up with an
application in the menu.

Nothing here runs as root, and there is no privileged helper, so there is no
privilege boundary to get wrong.
"""
from __future__ import annotations

import logging
import os
import re
import sys
from pathlib import Path

sys.path.insert(0, "/usr/share/motel/compat")

import catalog  # noqa: E402
import integrate  # noqa: E402
import prefix  # noqa: E402
from ui import Progress, ask_choice, ask_yes_no, show_error, show_info  # noqa: E402

logging.basicConfig(
    level=logging.INFO,
    format="%(levelname)s: %(message)s",
)
log = logging.getLogger("motel-compat")


# Names installers give themselves. Matching one is good evidence; matching
# none is not evidence of the opposite, which is why this only chooses the
# default answer rather than deciding on the user's behalf.
INSTALLER_HINTS = re.compile(
    r"(setup|install|installer|_inst|-inst|webinstall|redist|update)",
    re.IGNORECASE,
)

# The prefix that portable programs share. They were never installed, so
# giving each one its own Windows environment would cost gigabytes to isolate
# things that write to their own folder anyway.
PORTABLE_APP_ID = "portable"

# Where the answer to the Microsoft component licence question is kept, so it
# is asked once rather than at every installation.
CONSENT_PATH = Path(
    os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
) / "motel/compat-downloads.conf"


def downloads_accepted() -> bool:
    try:
        return "accepted=true" in CONSENT_PATH.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError):
        return False


def remember_acceptance() -> None:
    try:
        CONSENT_PATH.parent.mkdir(parents=True, exist_ok=True)
        CONSENT_PATH.write_text(
            "# Written by motel-compat when the Microsoft component terms\n"
            "# were accepted. Delete this file to be asked again.\n"
            "accepted=true\n",
            encoding="utf-8",
        )
    except OSError as exc:
        # Not fatal: the question is asked again next time, which is the safe
        # direction for a consent record to fail in.
        log.info("could not record the answer: %s", exc)


def confirm_downloads(recipe: catalog.Recipe, verbs: list[str]) -> bool:
    """Ask before fetching Microsoft's components on the user's behalf.

    These are not ours to ship — they are licensed for redistribution with an
    application that uses them, not as part of a distribution — so they are
    fetched from Microsoft, on this machine, under Microsoft's terms. Which
    means the user has to see those terms. winetricks would accept them
    silently, and a licence accepted on someone's behalf without their
    knowing is not consent.
    """
    if downloads_accepted():
        return True

    size = prefix.download_size_mb(verbs)
    accepted = ask_yes_no(
        title="Download components from Microsoft?",
        text=(
            f"<b>{recipe.name}</b> needs components that Microsoft publishes "
            f"and that we are not permitted to include with this system:\n\n"
            f"    {', '.join(verbs)}\n\n"
            f"They will be downloaded from Microsoft now — roughly {size} MB — "
            "and are covered by Microsoft's own licence terms, not ours.\n\n"
            "This is asked once. Applications that need nothing from Microsoft "
            "are never affected.\n\n"
            "Download them?"
        ),
    )
    if accepted:
        remember_acceptance()
    return accepted


def resolve_downloads(recipe: catalog.Recipe) -> bool | None:
    """Decide whether this installation may fetch Microsoft components.

    Returns True to fetch them, False to install without them, and None to
    cancel the installation entirely.
    """
    verbs = prefix.microsoft_verbs(recipe)
    if not verbs:
        # Nothing to fetch and nothing to ask about. Most applications.
        return True

    if not prefix.network_available():
        # Said before the progress dialog appears, rather than after a
        # half-hour timeout expires inside it.
        carry_on = ask_yes_no(
            title="No internet connection",
            text=(
                f"<b>{recipe.name}</b> needs components from Microsoft that "
                "are downloaded during installation, and this computer does "
                "not appear to be online.\n\n"
                "You can install it now — it may not work correctly — or "
                "connect to the internet and try again.\n\n"
                "Install without them?"
            ),
        )
        return False if carry_on else None

    if confirm_downloads(recipe, verbs):
        return True

    # Declining the licence is not the same as cancelling: offer the install
    # without the components, since some applications work regardless.
    carry_on = ask_yes_no(
        title=f"Install {recipe.name} without them?",
        text=(
            "The components will not be downloaded.\n\n"
            f"{recipe.name} may not work correctly without them. Install "
            "anyway?"
        ),
    )
    return False if carry_on else None


def derive_app_id(installer: Path, recipe: catalog.Recipe) -> str:
    if not recipe.is_generic:
        return recipe.app_id

    # Derive a stable id from the filename, stripped of version noise so
    # reinstalling a newer build reuses the same prefix instead of piling up.
    stem = installer.stem.lower()
    stem = re.sub(r"[._-]?v?\d+(\.\d+)*", "", stem)
    stem = re.sub(r"(setup|installer|install|x64|x86|win32|win64)", "", stem)
    stem = re.sub(r"[^a-z0-9]+", "-", stem).strip("-")
    return stem or "windows-app"


def confirm_unknown(installer: Path) -> bool:
    """Be honest about an untested installer rather than quietly failing."""
    return ask_yes_no(
        title="Install a Windows application?",
        text=(
            f"<b>{installer.name}</b> has not been tested with this system.\n\n"
            "It may install and run normally, or it may not work at all. "
            "Nothing else on your computer will be affected either way.\n\n"
            "Do you want to continue?"
        ),
    )


def confirm_known(recipe: catalog.Recipe, how: str) -> bool:
    detail = {
        "hash": "This exact installer is known and tested.",
        "filename": "This looks like a known installer, matched by its name.",
    }.get(how, "")

    if recipe.status == "broken":
        return ask_yes_no(
            title=f"Install {recipe.name}?",
            text=(
                f"<b>{recipe.name}</b> is known <b>not</b> to work correctly "
                f"on this system.\n\n{recipe.notes}\n\nInstall anyway?"
            ),
        )

    warning = ""
    if recipe.status == "partial":
        warning = "\n\nSome features are known not to work:\n" + recipe.notes

    return ask_yes_no(
        title=f"Install {recipe.name}?",
        text=f"{detail}{warning}\n\nContinue?",
    )


def main(argv: list[str]) -> int:
    if len(argv) != 2:
        print("usage: motel-compat-open <installer.exe|.msi>", file=sys.stderr)
        return 2

    installer = Path(argv[1]).expanduser().resolve()
    if not installer.is_file():
        show_error("File not found", f"{installer} does not exist.")
        return 1

    recipe, how = catalog.identify(installer)

    action = choose_action(installer, recipe)
    if action is None:
        return 0
    if action == "run":
        return run_portable(installer)

    if recipe.is_generic:
        if not confirm_unknown(installer):
            return 0
    elif not confirm_known(recipe, how):
        return 0

    allow_downloads = resolve_downloads(recipe)
    if allow_downloads is None:
        return 0

    app_id = derive_app_id(installer, recipe)

    with Progress(f"Installing {recipe.name}") as progress:
        try:
            # create() reports each step, including any download, through this.
            info = prefix.create(recipe, app_id,
                                 progress=progress.update,
                                 allow_downloads=allow_downloads)

            progress.update("Running the installer…")
            code = prefix.run_installer(info, installer)

            # A non-zero exit is not conclusive: plenty of Windows installers
            # return junk. Whether an executable appeared is the real test.
            progress.update("Looking for the installed application…")
            exe = integrate.find_main_executable(info)

            if exe is None:
                show_error(
                    "Installation did not complete",
                    "The installer finished but no application was found.\n\n"
                    f"The installer exited with code {code}.\n\n"
                    "You can remove the leftover files in Windows Applications.",
                )
                return 1

            progress.update("Adding it to your menu…")
            icon = integrate.extract_icon(exe, app_id)
            integrate.create_desktop_entry(info, exe, icon, recipe.categories)

            info.installed_exe = str(exe)
            info.desktop_file = f"motel-compat-{app_id}.desktop"
            prefix._write_metadata(info)

        except prefix.PrefixError as exc:
            show_error("Installation failed", str(exc))
            return 1
        except Exception:  # noqa: BLE001 - last resort, must not traceback at a user
            log.exception("unexpected failure")
            show_error(
                "Installation failed",
                "Something went wrong that we did not anticipate.\n\n"
                "Details are in the system log (journalctl --user).",
            )
            return 1

    show_info(
        f"{recipe.name} is installed",
        f"You will find it in your application menu.\n\n"
        "Windows applications have the same access to your files as any other "
        "application you run.",
    )
    return 0


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