/*
 * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.  Oracle designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Oracle in the LICENSE file that accompanied this code.
 *
 * This code is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 */
package jdk.jpackage.internal;

import static jdk.jpackage.internal.ApplicationImageUtils.createLauncherIconResource;
import static jdk.jpackage.internal.model.LauncherShortcut.toRequest;

import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;
import javax.imageio.ImageIO;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;
import jdk.jpackage.internal.model.FileAssociation;
import jdk.jpackage.internal.model.LauncherShortcut;
import jdk.jpackage.internal.model.LinuxLauncher;
import jdk.jpackage.internal.model.LinuxPackage;
import jdk.jpackage.internal.model.Package;
import jdk.jpackage.internal.util.CompositeProxy;
import jdk.jpackage.internal.util.PathUtils;
import jdk.jpackage.internal.util.XmlUtils;

/**
 * Helper to create files for desktop integration.
 */
final class DesktopIntegration extends ShellCustomAction {

    private static final String COMMANDS_INSTALL = "DESKTOP_COMMANDS_INSTALL";
    private static final String COMMANDS_UNINSTALL = "DESKTOP_COMMANDS_UNINSTALL";
    private static final String SCRIPTS = "DESKTOP_SCRIPTS";
    private static final String COMMON_SCRIPTS = "COMMON_SCRIPTS";

    private static final List<String> REPLACEMENT_STRING_IDS = List.of(
            COMMANDS_INSTALL, COMMANDS_UNINSTALL, SCRIPTS, COMMON_SCRIPTS);

    private DesktopIntegration(BuildEnv env, LinuxPackage pkg, LinuxLauncher launcher) {

        associations = launcher.fileAssociations().stream().map(
                LinuxFileAssociation::create).toList();

        this.env = env;
        this.pkg = pkg;
        this.launcher = launcher;

        // Need desktop and icon files if one of conditions is met:
        //  - there are file associations configured
        //  - user explicitly requested to create a shortcut
        boolean withDesktopFile = !associations.isEmpty() || toRequest(launcher.shortcut()).orElse(false);

        if (!launcher.hasIcon()) {
            // This is additional launcher with explicit `no icon` configuration.
            withDesktopFile = false;
        } else if (launcher.hasCustomIcon()) {
            // This launcher has custom icon configured.
            withDesktopFile = true;
        }

        desktopFileResource = env.createResource("template.desktop")
                .setCategory(I18N.getString("resource.menu-shortcut-descriptor"))
                .setPublicName(launcher.name() + ".desktop");

        final String escapedAppFileName = launcher.name().replaceAll("\\s+", "_");

        // XDG recommends to use vendor prefix in desktop file names as xdg
        // commands copy files to system directories.
        // Package name should be a good prefix.
        final String desktopFileName = String.format("%s-%s.desktop",
                    pkg.packageName(), escapedAppFileName);
        final String mimeInfoFileName = String.format("%s-%s-MimeInfo.xml",
                    pkg.packageName(), escapedAppFileName);

        mimeInfoFile = createDesktopFile(mimeInfoFileName);

        if (withDesktopFile) {
            desktopFile = Optional.of(createDesktopFile(desktopFileName));
            iconFile = Optional.of(createDesktopFile(escapedAppFileName + ".png"));
        } else {
            desktopFile = Optional.empty();
            iconFile = Optional.empty();
        }

        iconResource = createLauncherIconResource(launcher, env::createResource);

        desktopFileData = createDataForDesktopFile();

        if (launcher != pkg.app().mainLauncher().orElseThrow()) {
            nestedIntegrations = List.of();
        } else {
            nestedIntegrations = pkg.app().additionalLaunchers().stream().map(v -> {
                return (LinuxLauncher)v;
            }).filter(l -> {
                return toRequest(l.shortcut()).orElse(true);
            }).map(l -> {
                return new DesktopIntegration(env, pkg, l);
            }).toList();
        }
    }

    static ShellCustomAction create(BuildEnv env, Package pkg) {
        if (pkg.isRuntimeInstaller()) {
            return ShellCustomAction.nop(REPLACEMENT_STRING_IDS);
        }
        return new DesktopIntegration(env, (LinuxPackage) pkg,
                (LinuxLauncher) pkg.app().mainLauncher().orElseThrow());
    }

    @Override
    List<String> requiredPackages() {
        return Stream.of(List.of(this), nestedIntegrations).flatMap(
                List::stream).map(DesktopIntegration::requiredPackagesSelf).flatMap(
                List::stream).distinct().toList();
    }

    @Override
    protected List<String> replacementStringIds() {
        return REPLACEMENT_STRING_IDS;
    }

    @Override
    protected Map<String, String> createImpl() throws IOException {
        if (iconFile.isPresent()) {
            // Create application icon file.
            iconResource.orElseThrow().saveToFile(iconFile.orElseThrow().srcPath());
        }

        Map<String, String> data = new HashMap<>(desktopFileData);

        final ShellCommands shellCommands;
        if (desktopFile.isPresent()) {
            // Create application desktop description file.
            saveDesktopFile(data);

            // Shell commands will be created only if desktop file
            // should be installed.
            shellCommands = new ShellCommands();
        } else {
            shellCommands = null;
        }

        if (!associations.isEmpty()) {
            // Create XML file with mime types corresponding to file associations.
            createFileAssociationsMimeInfoFile();

            shellCommands.setFileAssociations();

            // Create icon files corresponding to file associations
            addFileAssociationIconFiles(shellCommands);
        }

        // Create shell commands to install/uninstall integration with desktop of the app.
        if (shellCommands != null) {
            shellCommands.applyTo(data);
        }

        // Take care of additional launchers if there are any.
        // Process every additional launcher as the main application launcher.
        // Collect shell commands to install/uninstall integration with desktop
        // of the additional launchers and append them to the corresponding
        // commands of the main launcher.
        List<String> installShellCmds = new ArrayList<>(Arrays.asList(
                data.get(COMMANDS_INSTALL)));
        List<String> uninstallShellCmds = new ArrayList<>(Arrays.asList(
                data.get(COMMANDS_UNINSTALL)));
        for (var integration: nestedIntegrations) {
            Map<String, String> launcherData = integration.create();

            installShellCmds.add(launcherData.get(COMMANDS_INSTALL));
            uninstallShellCmds.add(launcherData.get(COMMANDS_UNINSTALL));
        }

        data.put(COMMANDS_INSTALL, stringifyShellCommands(installShellCmds));
        data.put(COMMANDS_UNINSTALL, stringifyShellCommands(uninstallShellCmds));

        data.put(COMMON_SCRIPTS, stringifyTextFile("common_utils.sh"));
        data.put(SCRIPTS, stringifyTextFile("desktop_utils.sh"));

        return data;
    }

    private List<String> requiredPackagesSelf() {
        return desktopFile.map(file -> List.of("xdg-utils")).orElseGet(Collections::emptyList);
    }

    private Map<String, String> createDataForDesktopFile() {

        var installedLayout = pkg.asInstalledPackageApplicationLayout().orElseThrow();

        Map<String, String> data = new HashMap<>();
        data.put("APPLICATION_NAME", launcher.name());
        data.put("APPLICATION_DESCRIPTION", launcher.description());
        data.put("APPLICATION_ICON", iconFile.map(
                f -> f.installPath().toString()).orElse(null));
        data.put("DEPLOY_BUNDLE_CATEGORY", pkg.menuGroupName());
        data.put("APPLICATION_LAUNCHER", Enquoter.forPropertyValues().applyTo(
                installedLayout.launchersDirectory().resolve(launcher.executableNameWithSuffix()).toString()));
        data.put("STARTUP_DIRECTORY", launcher.shortcut()
                .flatMap(LauncherShortcut::startupDirectory)
                .map(startupDirectory -> {
                    switch (startupDirectory) {
                        case DEFAULT -> {
                            return (Path)null;
                        }
                        case APP_DIR -> {
                            return installedLayout.appDirectory();
                        }
                        default -> {
                            throw new AssertionError();
                        }
                    }
                }).map(str -> {
                    return "Path=" + str;
                }).orElse(null));

        return data;
    }

    /**
     * Shell commands to integrate something with desktop.
     */
    private class ShellCommands {

        ShellCommands() {
            registerIconCmds = new ArrayList<>();
            unregisterIconCmds = new ArrayList<>();

            final var desktopFileInstallPath = desktopFile.orElseThrow().installPath().toString();

            registerDesktopFileCmd = String.join(" ",
                    "xdg-desktop-menu", "install", desktopFileInstallPath);
            unregisterDesktopFileCmd = String.join(" ",
                    "do_if_file_belongs_to_single_package", desktopFileInstallPath,
                    "xdg-desktop-menu", "uninstall", desktopFileInstallPath);
        }

        void setFileAssociations() {
            registerFileAssociationsCmd = String.join(" ", "xdg-mime",
                    "install",
                    mimeInfoFile.installPath().toString());
            unregisterFileAssociationsCmd = String.join(" ",
                    "do_if_file_belongs_to_single_package", mimeInfoFile.
                            installPath().toString(), "xdg-mime", "uninstall",
                    mimeInfoFile.installPath().toString());

            final var desktopFileInstallPath = desktopFile.orElseThrow().installPath();

            //
            // Add manual cleanup of system files to get rid of
            // the default mime type handlers.
            //
            // Even after mime type is unregistered with `xdg-mime uninstall`
            // command and desktop file deleted with `xdg-desktop-menu uninstall`
            // command, records in
            // `/usr/share/applications/defaults.list` (Ubuntu 16) or
            // `/usr/local/share/applications/defaults.list` (OracleLinux 7)
            // files remain referencing deleted mime time and deleted
            // desktop file which makes `xdg-mime query default` output name
            // of non-existing desktop file.
            //
            String cleanUpCommand = String.join(" ",
                    "do_if_file_belongs_to_single_package", desktopFileInstallPath.toString(),
                    "desktop_uninstall_default_mime_handler", desktopFileInstallPath.getFileName().toString(),
                    String.join(" ", getMimeTypeNamesFromFileAssociations()));

            unregisterFileAssociationsCmd = stringifyShellCommands(
                    unregisterFileAssociationsCmd, cleanUpCommand);
        }

        void addIcon(String mimeType, Path iconFile, int imgSize) {
            imgSize = normalizeIconSize(imgSize);
            final String dashMime = mimeType.replace('/', '-');
            registerIconCmds.add(String.join(" ", "xdg-icon-resource",
                    "install", "--context", "mimetypes", "--size",
                    Integer.toString(imgSize), iconFile.toString(), dashMime));
            unregisterIconCmds.add(String.join(" ",
                    "do_if_file_belongs_to_single_package", iconFile.toString(),
                    "xdg-icon-resource", "uninstall", dashMime, "--size",
                    Integer.toString(imgSize)));
        }

        void applyTo(Map<String, String> data) {
            List<String> cmds = new ArrayList<>();

            cmds.add(registerDesktopFileCmd);
            cmds.add(registerFileAssociationsCmd);
            cmds.addAll(registerIconCmds);
            data.put(COMMANDS_INSTALL, stringifyShellCommands(cmds));

            cmds.clear();
            cmds.add(unregisterDesktopFileCmd);
            cmds.add(unregisterFileAssociationsCmd);
            cmds.addAll(unregisterIconCmds);
            data.put(COMMANDS_UNINSTALL, stringifyShellCommands(cmds));
        }

        private String registerDesktopFileCmd;
        private String unregisterDesktopFileCmd;

        private String registerFileAssociationsCmd;
        private String unregisterFileAssociationsCmd;

        private List<String> registerIconCmds;
        private List<String> unregisterIconCmds;
    }

    /**
     * Creates desktop integration file. xml, icon, etc.
     *
     * Returned instance:
     *  - srcPath(): path where it should be placed at package build time;
     *  - installPath(): path where it should be installed by package manager;
     */
    private InstallableFile createDesktopFile(String fileName) {
        var srcPath = env.asApplicationLayout().orElseThrow().desktopIntegrationDirectory().resolve(fileName);
        var installPath = pkg.asInstalledPackageApplicationLayout().orElseThrow().desktopIntegrationDirectory().resolve(fileName);
        return new InstallableFile(srcPath, installPath);
    }

    private void appendFileAssociation(XMLStreamWriter xml,
            LinuxFileAssociation fa) throws XMLStreamException {

            xml.writeStartElement("mime-type");
            xml.writeAttribute("type", fa.mimeType());

            final var description = fa.description().orElse(null);
            if (description != null) {
                xml.writeStartElement("comment");
                xml.writeCharacters(description);
                xml.writeEndElement();
            }

            xml.writeStartElement("glob");
            xml.writeAttribute("pattern", "*." + fa.extension());
            xml.writeEndElement();

            xml.writeEndElement();
    }

    private void createFileAssociationsMimeInfoFile() throws IOException {
        XmlUtils.createXml(mimeInfoFile.srcPath(), xml -> {
            xml.writeStartElement("mime-info");
            xml.writeDefaultNamespace(
                    "http://www.freedesktop.org/standards/shared-mime-info");

            for (var fa : associations) {
                appendFileAssociation(xml, fa);
            }

            xml.writeEndElement();
        });
    }

    private void addFileAssociationIconFiles(ShellCommands shellCommands)
            throws IOException {
        Set<String> processedMimeTypes = new HashSet<>();
        for (var fa : associations) {
            if (!fa.hasIcon()) {
                // No icon.
                continue;
            }

            var mimeType = fa.mimeType();
            if (processedMimeTypes.contains(mimeType)) {
                continue;
            }

            processedMimeTypes.add(mimeType);

            final var faIcon = fa.icon().orElseThrow();

            // Create icon name for mime type from mime type.
            var faIconFile = createDesktopFile(mimeType.replace(File.separatorChar,
                    '-') + PathUtils.getSuffix(faIcon));

            IOUtils.copyFile(faIcon, faIconFile.srcPath());

            shellCommands.addIcon(mimeType, faIconFile.installPath(), fa.iconSize());
        }
    }

    private void saveDesktopFile(Map<String, String> data) throws IOException {
        List<String> mimeTypes = getMimeTypeNamesFromFileAssociations();
        data.put("DESKTOP_MIMES", "MimeType=" + String.join(";", mimeTypes));

        // prepare desktop shortcut
        desktopFileResource
                .setSubstitutionData(data)
                .saveToFile(desktopFile.orElseThrow().srcPath());
    }

    private List<String> getMimeTypeNamesFromFileAssociations() {
        return associations.stream().map(FileAssociation::mimeType).toList();
    }

    private static int getSquareSizeOfImage(Path path) {
        try {
            BufferedImage bi = ImageIO.read(path.toFile());
            return Math.max(bi.getWidth(), bi.getHeight());
        } catch (IOException e) {
            Log.verbose(e);
        }
        return 0;
    }

    private static int normalizeIconSize(int iconSize) {
        // If register icon with "uncommon" size, it will be ignored.
        // So find the best matching "common" size.
        List<Integer> commonIconSizes = List.of(16, 22, 32, 48, 64, 128);

        int idx = Collections.binarySearch(commonIconSizes, iconSize);
        if (idx < 0) {
            // Given icon size is greater than the largest common icon size.
            return commonIconSizes.get(commonIconSizes.size() - 1);
        }

        if (idx == 0) {
            // Given icon size is less or equal than the smallest common icon size.
            return commonIconSizes.get(idx);
        }

        int commonIconSize = commonIconSizes.get(idx);
        if (iconSize < commonIconSize) {
            // It is better to scale down original icon than to scale it up for
            // better visual quality.
            commonIconSize = commonIconSizes.get(idx - 1);
        }

        return commonIconSize;
    }

    private interface LinuxFileAssociationMixin {
        int iconSize();

        record Stub(int iconSize) implements LinuxFileAssociationMixin {}
    }

    private static interface LinuxFileAssociation extends FileAssociation, LinuxFileAssociationMixin {
        static LinuxFileAssociation create(FileAssociation fa) {
            var iconSize = getIconSize(fa);
            if (iconSize <= 0) {
                // nullify the icon
                fa = new FileAssociation.Stub(fa.description(), Optional.empty(),
                        fa.mimeType(), fa.extension());
            }
            return CompositeProxy.build()
                    .invokeTunnel(CompositeProxyTunnel.INSTANCE)
                    .create(LinuxFileAssociation.class, fa,
                            new LinuxFileAssociationMixin.Stub(iconSize));
        }

        private static int getIconSize(FileAssociation fa) {
            return Optional.of(fa)
                    .flatMap(FileAssociation::icon)
                    .map(DesktopIntegration::getSquareSizeOfImage)
                    .orElse(-1);
        }
    }

    private final BuildEnv env;
    private final LinuxPackage pkg;
    private final LinuxLauncher launcher;

    private final List<LinuxFileAssociation> associations;

    private final Optional<OverridableResource> iconResource;
    private final OverridableResource desktopFileResource;

    private final InstallableFile mimeInfoFile;
    private final Optional<InstallableFile> desktopFile;
    private final Optional<InstallableFile> iconFile;

    private final List<DesktopIntegration> nestedIntegrations;

    private final Map<String, String> desktopFileData;
}
