//////////////////////////////////////////////////////////////////////////////////////////
//   _  _ ____ _  _ ___  ____                                                           //
//   |_/  |__| |\ | |  \ |  |    This file belongs to Kando, the cross-platform         //
//   | \_ |  | | \| |__/ |__|    pie menu. Read more on github.com/kando-menu/kando     //
//                                                                                      //
//////////////////////////////////////////////////////////////////////////////////////////

// SPDX-FileCopyrightText: Simon Schneegans <code@simonschneegans.de>
// SPDX-License-Identifier: MIT

import fs from 'fs';
import os from 'os';
import path from 'path';
import DBus from 'dbus-final';
import { EventEmitter } from 'events';

import { desktopName as APP_ID } from '../../../../../package.json';

const FALLBACK_DESKTOP_CONTENT = `# This file is automatically generated by Kando when no desktop file for the app ID is
# found. If you want to customize it, remove the X-Kando-PortalFallback key and edit
# the file in place. It will not be overwritten by Kando anymore.

[Desktop Entry]
Version=1.0
Type=Application
Name=Kando
Comment=Portal app-id fallback for development runs
Categories=Utility;
Icon=menu.kando.Kando
# We don't really want this to be executed. Yet g_desktop_app_info_new() will check
# whether the Exec value is valid and fail if it isn't.
Exec=/usr/bin/true
NoDisplay=true
Terminal=false
# This is used to identify fallback files that we can safely overwrite in the future
# when the app ID or other details change.
X-Kando-PortalFallback=true
`;

/**
 * The type information of DBus.MessageBus does not expose the name of the bus, even if
 * the property is actually there. This type is used to fix this.
 */
type NamedMessageBus = {
  name: string;
} & DBus.MessageBus;

/**
 * This is the base class for all portals. It provides some common functionality like
 * generating tokens and making requests. It extends the EventEmitter class so that
 * derived classes can emit events.
 */
export class DesktopPortal extends EventEmitter {
  private bus = DBus.sessionBus() as NamedMessageBus;

  /**
   * This is the proxy object for the org.freedesktop.portal.Desktop interface. It should
   * be used by derived classes to retrieve the actual portal methods.
   */
  protected portals: DBus.ProxyObject;

  /**
   * This promise is used to ensure that the app ID registration is only performed once
   * per process.
   */
  static registrationPromise: Promise<void>;

  /**
   * This method initializes the portal. It must be called before any other method is
   * called.
   */
  protected async init() {
    this.portals = await this.bus.getProxyObject(
      'org.freedesktop.portal.Desktop',
      '/org/freedesktop/portal/desktop'
    );

    // Register once per process before using portal methods. Some newer portal APIs
    // require an app ID for host applications.
    await this.registerApp(this.portals);
  }

  /**
   * Most portal methods which return a value use the same pattern: A request is made and
   * a response is received. This method takes care of this pattern.
   *
   * @param method This will be called when the request is ready to be dispatched. A
   *   request token is given to this method, this is usually required for the options
   *   vardict of the actual request method. The method should return the promise of the
   *   underlying D-Bus call so that errors can be propagated.
   * @returns A promise which resolves when the request has been processed.
   * @see https://flatpak.github.io/xdg-desktop-portal/#idm9
   */
  protected async makeRequest(
    method: (request: { token: string; path: string }) => Promise<unknown> | void
  ) {
    return new Promise<DBus.Message>((resolve, reject) => {
      const request = this.generateToken('request');

      const responseListener = (message: DBus.Message) => {
        if (message.path === request.path) {
          if (message.member !== 'Response') {
            reject(`Got unexpected portal response: ${message.member}`);
          }

          this.bus.removeListener('message', responseListener);
          resolve(message);
        }
      };

      this.bus.addListener('message', responseListener);

      // If the D-Bus call itself fails (for instance because the portal returns an error
      // instead of replying with a Response signal), we have to reject the promise here.
      // Otherwise it would never resolve and the unhandled rejection would crash the app.
      Promise.resolve(method(request)).catch((error) => {
        this.bus.removeListener('message', responseListener);
        reject(error);
      });
    });
  }

  /**
   * Generates a token and a path for a request or session. Request tokens are used by the
   * makeRequest method above, session can be used by derived classes.
   *
   * @param type The type of the token to generate, either 'request' or 'session'.
   * @returns A token and a path for the request.
   * @see https://flatpak.github.io/xdg-desktop-portal/#gdbus-org.freedesktop.portal.Request
   */
  protected generateToken(type: 'request' | 'session') {
    const token = 'kando_' + Math.floor(Math.random() * 0x100000000);
    const sender = this.bus.name.slice(1).replace(/\./g, '_');
    const path = `/org/freedesktop/portal/desktop/${type}/${sender}/${token}`;

    return { token, path };
  }

  /**
   * Registers Kando's app ID with xdg-desktop-portal's host registry. On newer portal
   * versions, some APIs (e.g. GlobalShortcuts) require a known app ID for host apps.
   *
   * This function is intentionally safe to call multiple times and from different portal
   * implementations; the registration will happen only once per process.
   *
   * @param portals Proxy object for org.freedesktop.portal.Desktop.
   */
  private async registerApp(portals: DBus.ProxyObject) {
    if (!DesktopPortal.registrationPromise) {
      DesktopPortal.registrationPromise = this.registerAppImpl(portals);
    }

    await DesktopPortal.registrationPromise;
  }

  /**
   * This method implements the actual registration logic. It first tries to register the
   * app ID with the portal registry and only creates a fallback .desktop file if that
   * initial attempt fails.
   *
   * @param portals Proxy object for org.freedesktop.portal.Desktop.
   */
  private async registerAppImpl(portals: DBus.ProxyObject) {
    const registry = portals.getInterface('org.freedesktop.host.portal.Registry');

    try {
      await registry.Register(APP_ID, {});
    } catch (e) {
      try {
        this.ensureDesktopFile(APP_ID);
        await registry.Register(APP_ID, {});
      } catch (retryError) {
        // Failing to register is not fatal. On older portal versions the interface may
        // not exist; on newer versions, individual portal calls will report a clear
        // error.
        console.warn('Failed to register with xdg-desktop-portal registry:', retryError);
      }
    }
  }

  /** Creates or updates the fallback .desktop file in ~/.local/share/applications/. */
  private ensureDesktopFile(appId: string) {
    const fileName = `${appId}.desktop`;
    const userAppsDir = path.join(os.homedir(), '.local', 'share', 'applications');
    const userDesktopFile = path.join(userAppsDir, fileName);

    fs.mkdirSync(userAppsDir, { recursive: true });
    fs.writeFileSync(userDesktopFile, FALLBACK_DESKTOP_CONTENT, 'utf-8');
  }
}
