#!/bin/env node

const fs = require('fs');
const path = require("node:path");
const child_process = require("node:child_process");
const constants = require("node:constants");
const os = require('os');

/**
 * @typedef {{
 *   message: string,
 *   summary: string,
 *   expire: number,
 *   printId: boolean,
 *   replaceid: number,
 *   urgency: "low"|"normal"|"critical"
 * }} Notification
 *
 * @typedef {{
 *   replace(notification: Partial<Notification>),
 *   remove()
 * }} ReplyNotification
 */
class NotificationHandler {
  static #NOTIFICATION_COMMAND = 'notify-send -a "Remap Orchestrator" -i "/usr/share/icons/Gruvbox-Material-Dark/64x64/devices/input-keyboard.svg" -c "system,remapping"'

  static NOTIFICATION_MAX_EXPIRE = 2**16;
  
  /**
   * @param {Partial<Notification>} notification
   * @return {string}
   */
  #createNotificationCommand(notification) {
    const command = [NotificationHandler.#NOTIFICATION_COMMAND];

    if (notification.expire) {
      command.push(`-t ${notification.expire}`);
    }

    if (notification.printId ?? false) {
      command.push("-p");
    }

    if (notification.replaceid) {
      command.push(`-r ${notification.replaceid}`);
    }

    if (notification.urgency) {
      command.push(`-u ${notification.urgency}`)
    }

    command.push(`"${notification.summary ?? ''}"`);
    command.push(`"${notification.message ?? ''}"`);

    return command.join(' ');
  }

  /**
   * @param {Partial<Notification>} notification
   * @param {boolean} sync
   * @returns {Buffer|ChildProcess}
   */
  sendNotification(notification, sync = false) {
    const command = this.#createNotificationCommand({
      expire: 5_000,
      ...notification
    })

    if (sync) {
      return child_process.execSync(command);
    }

    return child_process.exec(command);
  }

  /**
   * @param {Partial<Notification>} notification
   * @returns {ReplyNotification}
   */
  sendReplaceableNotification(notification) {
    const id = parseInt(this.sendNotification({
      ...notification,
      printId: true,
      expire: NotificationHandler.NOTIFICATION_MAX_EXPIRE
    }, true).toString())

    const sendNotification = this.sendNotification.bind(this);
    return {
      /**
       * @param {Partial<Notification>} notification
       */
      replace(notification) {
        sendNotification({
          ...notification,
          replaceid: id
        })
      },
      remove() {
        sendNotification(
          {
            replaceid: id,
            expire: 1
          }
        );
      }
    }
  }
}

/**
 * @typedef {"DEBUG"|"INFO"|"WARN"|"ERROR"|"CRITICAL"} LogType
 * @typedef {"ORCHESTRATOR"|string} LogOrigin
 * @typedef {"CHATTY"|"INFORMATIONAL"|"DEFAULT"|"ERRORSONLY"} VerboseMode
 */
class Logger {
  /**
   * @type {Map<VerboseMode, LogType[]>}
   */
  static SHOULD_SEND_MAP = new Map(
    [
      [ 'CHATTY', ['DEBUG', 'INFO', 'WARN', 'ERROR', 'CRITICAL'] ],
      [ 'INFORMATIONAL', ['INFO', 'WARN', 'ERROR', 'CRITICAL'] ],
      [ 'DEFAULT', ['WARN', 'ERROR', 'CRITICAL'] ],
      [ 'ERRORSONLY', ['ERROR', 'CRITICAL'] ]
    ]
  )
  
  /** @type {VerboseMode} */
  #verboseMode;
  
  /**
   * @param {VerboseMode} verboseMode
   */
  constructor(verboseMode) {
    this.#verboseMode = verboseMode;
  }
  
  /**
   * @param {string} message
   * @param {LogOrigin} origin
   * @param {LogType} type
   * @param {Date|null} date
   */
  log(
    message,
    origin = "ORCHESTRATOR",
    type = "INFO",
    date = null
  ) {
    if (!this.#shouldSend(type)) {
      return;
    }
    
    const method = this.#getMethod(type);
    
    if (!date) {
      date = new Date();
    }
    
    const prefix = `[${date.toISOString()}] ${origin}:${type}>`;
    const content = this.#getMessageLines(prefix, message);
    method(content);
  }

  /**
   * @param {LogType} type
   */
  #getMethod(type) {
    switch (type) {
      case "DEBUG":
        return console.debug;
      default:
      case "INFO":
        return console.info;
      case "WARN":
        return console.warn;
      case "ERROR":
      case "CRITICAL":
        return console.error;
    }
  }

  /**
   * @param {string} prefix
   * @param {string} message
   */
  #getMessageLines(prefix, message) {
    return message.split(os.EOL)
      .filter(line => line)
      .map((line, index) => `${prefix} ${index > 0 ? '  ' : ''}${line}`)
      .join(os.EOL);
  }
  
  /**
   * @param {LogType} type
   * @returns {boolean}
   */
  #shouldSend(type) {
    return Logger.SHOULD_SEND_MAP.get(this.#verboseMode).includes(type);
  }
}

class HyprlandSupport {

  static HYPR_INPUT_CONFIG_FILE = path.join(process.env.HOME, '.config/hypr/config/settings/Input.conf')
  static TARGET_CONFIG_FILE = path.join(process.env.HOME, '.cache/evremap_hyprland_config.conf')
  
  #lastFile = '';
  
  /**
   * @returns {boolean}
   */
  get isValid() {
    return process.env.DESKTOP_SESSION === 'hyprland';
  }

  /**
   * @param {FileContainer[]} activeFiles
   */
  update(files) {
    const configurations = this.#getConfigurations();
    const activeFiles = files.filter((file) => file.state === 'running');
    
    const activeConfigurations = 
      configurations.filter((config) => 
        activeFiles.some((file) => {
          const normalisedDeviceName = this.#getNormalizedDeviceName(file.device.name)
          return config.name === normalisedDeviceName
        })
      )
    
    this.#createFile(activeFiles, activeConfigurations)
  }

  #getConfigurations() {
    const config = fs.readFileSync(HyprlandSupport.HYPR_INPUT_CONFIG_FILE, 'utf-8');
    const lines = config.split('\n').filter(line => line);
    
    const configurations = [];
    let currentConfiguration;
    let inDevice = false;
    for (const line of lines) {
      if (!inDevice) {
        if (line === 'device {') {
          inDevice = true;
          
          currentConfiguration = {};
        }
        continue;
      }
      
      if (line === '}') {
        inDevice = false;
        
        configurations.push(currentConfiguration);
        currentConfiguration = {};
        continue;
      }
      
      const [ name, value ] = line.split('=');
      currentConfiguration[name.trim()] = value.trim()
    }
    
    return configurations;
  }

  #createFile(files, configurations) {
    let configFileContent = "";

    for (const file of files) {
      const configuration = configurations.find(
        (config) => config.name === this.#getNormalizedDeviceName(file.device.name)
      )
      if (!configuration) {
        continue
      }
      
      configuration.name = `evremap-virtual-input-for-${file.device.path}`;
      
      const entry = Object.entries(configuration)
        .map(([name, value]) => `  ${name} = ${value}`)
        .join('\n');
      
      configFileContent += `device {\n${entry}\n}\n`;
    }
    
    if (this.#lastFile === configFileContent) {
      return;
    }
    
    this.#lastFile = configFileContent
    
    fs.writeFileSync(HyprlandSupport.TARGET_CONFIG_FILE, configFileContent);
    console.log("Writing new config")
  }

  /**
   * @param {string} deviceName
   * @returns {string}
   */
  #getNormalizedDeviceName(deviceName) {
    return deviceName.replaceAll(' ', '-').toLowerCase();
  }
}

/**
 * @typedef {"waiting"|"running"|"failed"|"destroy"|"invalid"} FileContainerState
 *
 * @typedef {{
 *     filepath: Path,
 *     device: Device,
 *     process?: ChildProcess,
 *     state: FileContainerState
 * }} FileContainer
 *
 * @typedef {string} Path
 * @typedef {string} DeviceName
 * 
 * @typedef {{
 *   name: DeviceName
 *   path: string,
 *   physical: string,
 * }} Device
 **/

class Orchestrator {
  
  /** @type string */
  static REMAP_COMMAND_TEMPLATE = 'evremap remap {file}';
  
  static NOTIFICATION_STARTING = `Starting remap for {device}...`
  static NOTIFICATION_READY = 'Remap for {device} ready'
  static NOTIFICATION_CONNECTION_LOST = 'Lost connection to {device}...'
  static NOTIFICATION_GENERIC_PROCESS_STOP = "Remap process for '{device}' was requested to stop.";
  static NOTIFICATION_MISSING_FILE_PROCESS_STOP = "Remap process for '{device}' was requested to stop, due to a missing file.";
  static NOTIFICATION_INVALID_FILE_PROCESS_STOP = "Remap process for '{device}' was requested to stop, due to a invalid file.";
  /**
   * @type {Map<Path, FileContainer>}
   */
  #fileMap
  /**
   * @type Set<Device>
   */
  #devices

  /**
   * @type {string}
   */
  #path

  /** @type {NotificationHandler} */
  #notifications
  /** @type {Logger} */
  #logger
  
  /** @type {HyprlandSupport} */
  #support;

  /** @type {'waiting'|'running'|'stopping'|'stopped'|'failed'} */
  #state
  constructor(
    path,
    logger,
    support
  ) {
    this.#path = path
    this.#fileMap = new Map();
    this.#devices = new Set();
    this.#notifications = new NotificationHandler();
    this.#logger = logger;
    this.#state = 'waiting';
    this.#support = support;
  }

  async run() {
    if (!this.#hasRightToRun()) {
      this.#logger.log("Can not run orchestrator!", "ORCHESTRATOR", "CRITICAL")
      this.#notifications.sendNotification({
        summary: "Unable to run Remap Orchestrator",
        message: "Evremap requires additional rights from the user to run. Please see the evremap GitHub page under 'Running it' to learn how to set the rights.",
        expire: 20_000
      })
      this.#state = 'failed';
      return;
    }
    
    this.#state = 'running';
    do {
      await this.#updateDevices();
      await this.#updateFileMap();
      
      this.#actFiles()
      
      if (this.#support.isValid) {
        this.#support.update([...this.#fileMap.values()]);
      }
      
      await new Promise(resolve => setTimeout(resolve, 1000));
    } while(this.#state === 'running');
    this.#state = 'stopped';
  }
  
  stop() {
    if (['waiting', 'failed', 'stopping'].includes(this.#state)) {
      return;
    }
    
    this.#state = 'stopping';
    
    this.#fileMap.values().forEach((file) => {
      this.#destroyProcess(file, 'GENERIC');
    })
  }

  async #updateFileMap() {
    const directory = await fs.promises.opendir(this.#path);

    const availableFiles = [];
    let directoryEntry;
    while (directoryEntry = await directory.read()) {
      if (directoryEntry.name.includes('##')) {
        // YADM alternative files. We want only the resulting file.
        continue;
      }

      if (!directoryEntry.name.endsWith('.toml')) {
        // evremap files ends with toml
        continue;
      }

      const filePath = path.resolve(directoryEntry.parentPath, directoryEntry.name);
      availableFiles.push(filePath);

      if (this.#fileMap.has(filePath)) {
        continue;
      }

      this.#fileMap.set(filePath, this.#createFileContainer(filePath));
    }
    await directory.close();

    [...this.#fileMap.values()]
      .filter(fileContainer => !availableFiles.includes(fileContainer.filepath))
      .forEach(value => {
        this.#fileMap.set(value.filepath, {
          ...value,
          state: "destroy"
        })
        this.#logger.log(
          `Marked ${value.filepath} as 'destroy' since file doesn't exist anymore...`, 
          "ORCHESTRATOR", 
          'DEBUG'
        );
      })
    
    this.#checkDeviceDuplicates();
  }

  /**
   * @param {Path} path
   * @returns {FileContainer}
   */
  #createFileContainer(path) {
    const grepResult = child_process.execSync(`grep device_name "${path}"`).toString();
    const deviceName = grepResult.split('=')[1].replaceAll('"', '').trim();

    this.#logger.log(
      `Create file watcher for ${deviceName} with '${path}'`,
      "ORCHESTRATOR",
      "DEBUG"
    )

    /**
     * @type {Device}
     */
    const selectedDevice = [...this.#devices.values()].find((device) => device.name === deviceName) ?? { name: deviceName }
    
    return {
      filepath: path,
      state: "waiting",
      device: selectedDevice
    }
  }
  
  #checkDeviceDuplicates() {
    /**
     * @type {Map<DeviceName, FileContainer[]>}
     */
    const deviceMap = new Map();
    for (let value of this.#fileMap.values()) {
      if (value.state === 'destroy') {
        continue;
      }
      
      if (!deviceMap.has(value.device.name)) {
        deviceMap.set(value.device.name, [])
      }
      
      deviceMap.get(value.device.name).push(value);
    }
    
    const duplicates = [...deviceMap.values()].filter(devices => devices.some(device => device.state !== 'invalid') && devices.length > 1);
    for (let duplicate of duplicates) {
      const container = duplicate[0];
      this.#notifications.sendNotification(
        {
          summary: "Duplicate remap files for device",
          message: `The following device ${container.device.name} has multiple remap files. This is not possible. Please make sure only one is avaliable.
Marking all files as invalid and unloading currently loaded files...

Files:
${duplicate.map((file) => `- ${file.filepath}`).join('\n')}`,
          expire: NotificationHandler.NOTIFICATION_MAX_EXPIRE
        }
      )
      
      duplicate.forEach((file) => {
        this.#fileMap.set(file.filepath, {
          ...file,
          state: "invalid"
        });
      })
      this.#logger.log(
        `Marking device ${container.device.name} invalid due to multiple files.
Files:
${duplicate.map((file) => `- ${file.filepath}`).join('\n')}\`
`,
        "ORCHESTRATOR",
        "WARN"
      );
    }
    
    const previousDuplicates = [...deviceMap.values()].filter(devices => devices[0].state === 'invalid' && devices.length < 2);
    previousDuplicates.forEach((previousDuplicate) => {
      const duplicate = previousDuplicate[0];
      this.#fileMap.set(duplicate.filepath, {
        ...duplicate,
        state: 'waiting'
      })
      
      this.#logger.log(
        `==> Marking device ${duplicate.device.name} as valid, due to having only one file`,
        'ORCHESTRATOR',
        'INFO'
      );
    })
  }

  async #updateDevices() {
    /**
     * @var {string}
     */
    const evremapResult = await new Promise((resolve, reject) => {
      child_process.exec('evremap list-devices', (error, stdout, stderr) => {
        if (error) {
          reject(error);
          return;
        }
        
        resolve(stdout);
      })
    })

    const lines = evremapResult.split('\n').filter(line => line);

    /**
     * @type {Device[]}
     */
    const devices = [];
    for (let i = 0; i < lines.length; i += 3) {
      const nameLine = lines[i];
      const pathLine = lines[i + 1];
      const physLine = lines[i + 2];

      /**
       * @type {Device}
       */
      const device = {
        name: nameLine.split(':')[1]?.trim() ?? '',
        path: pathLine.split(':')[1]?.trim() ?? '',
        physical: physLine.split(':')[1]?.trim() ?? ''
      }
      
      devices.push(device);
    }
    this.#devices = new Set(devices);
    
  }

  #actFiles() {
    for (let file of this.#fileMap.values()) {
      switch (file.state) {
        case "running":
          continue;
        case "failed":
        case "waiting":
          this.#runProcess(file);
          break;
        case "destroy":
          this.#fileMap.delete(file.filepath);
          this.#destroyProcess(file, 'REMOVED_FILE');
          break;
        case 'invalid':
          this.#destroyProcess(file, 'INVALID_FILE');
          break;
      }
    }
  }
s

  /**
   * @param {FileContainer} file
   */
  #runProcess(file) {
    if (!this.#devices.has(file.device)) {
      return;
    }
    
    const message = this.#notifications.sendReplaceableNotification({
      summary: "Please wait...",
      message: Orchestrator.NOTIFICATION_STARTING.replace('{device}', file.device.name),
      urgency: "critical",
    });
    this.#logger.log(
      `Started remap for device '${file.device}' with file '${file.filepath}'`,
      'ORCHESTRATOR',
      'INFO'
    );
    
    const command = Orchestrator.REMAP_COMMAND_TEMPLATE.replace('{file}', file.filepath)
    const process = child_process.exec(command);
    process.stderr.on('data', (data) => {
      data = data.toString();
      if (data.includes("Going into read loop")) {
        message.replace({
          summary: "Device ready",
          message: Orchestrator.NOTIFICATION_READY.replace("{device}", file.device.name),
          expire: 5_000,
          urgency: 'normal'
        })
      }
      
      const [dataMetadata, dataMessage] = data.split('] ');
      if (!dataMessage) {
        this.#logger.log(
          dataMetadata,
          `EVREMAP[${file.device.name}]`,
          'WARN',
        )
        return;
      } 
      
      const [time, type, origin ] = dataMetadata.replace('[', '').split(' ').filter(value => value);
      
      this.#logger.log(
        dataMessage.trim(), 
        origin.toUpperCase().replace('EVREMAP', `EVREMAP[${file.device.name}]`),
        type,
        new Date(Date.parse(time))
      )
    })
    process.on('exit', (code, signal) => {
      const unexpected = code === null || code === 0;
      
      this.#logger.log(
        `Device '${file.device.name}' ${unexpected ? "unexpectedly" : ''} left with code '${code}'.`,
        `EVREMAP[${file.device.name}]`,
        unexpected ? 'WARN' : 'INFO'
      )
      
      if (unexpected) {
        return
      }
      
      message.replace({
        summary: "Device left unexpected...",
        message: Orchestrator.NOTIFICATION_CONNECTION_LOST.replace('{device}', file.device.name)
      })
      
      this.#fileMap.set(file.filepath, {
        ...file, 
        state: 'failed'
      })
    })
    
    this.#fileMap.set(file.filepath, {
      ...file,
      process: process,
      state: 'running'
    })
  }

  /**
   * @param {FileContainer} file
   * @param {"GENERIC", "REMOVED_FILE", "INVALID_FILE"} reason
   */
  #destroyProcess(file, reason) {
    if (!file.process) {
      return;
    }
    
    if (file.process.exitCode !== null || file.process.killed) {
      return;
    }
    
    let notificationMessage = '';
    let logMessage = '';
    
    switch (reason) {
      default:
      case "GENERIC":
        notificationMessage = Orchestrator.NOTIFICATION_GENERIC_PROCESS_STOP.replace('{device}', file.device.name)
        logMessage = `Removing process for device '${file.device.name}' from file '${file.filepath}', due to unknown reason`
        break;
      case "REMOVED_FILE":
        notificationMessage = Orchestrator.NOTIFICATION_MISSING_FILE_PROCESS_STOP.replace('{device}', file.device.name)
        logMessage = `Removing process for device '${file.device.name}' from file '${file.filepath}', due to a removed file`
        break;
      case "INVALID_FILE":
        notificationMessage = Orchestrator.NOTIFICATION_INVALID_FILE_PROCESS_STOP.replace('{device}', file.device.name)
        logMessage = `Removing process for device '${file.device.name}' from file '${file.filepath}', due to a invalid file`
        break;

    }
    
    this.#logger.log(
      logMessage,
      'ORCHESTRATOR',
      'INFO'
    )
    
    this.#notifications.sendNotification({
      summary: "Stopping process",
      message: notificationMessage,
    })
    
    file.process.kill("SIGTERM");
  }
  
  #hasRightToRun() {
    try {
      const result = child_process.execSync('evremap list-devices').toString();
      return !result.includes('os error 13');
      
    } catch (e) {
      this.#logger.log(
        e.toString(),
        'ORCHESTRATOR',
        'ERROR'
      )
      return false;
    }
  }
}

/**
 * @param {Orchestrator} orchestrator
 */
function prepareProcess(orchestrator) {
  const stopMethod = orchestrator.stop.bind(orchestrator);
  process.on('SIGTERM', stopMethod);
  process.on('SIGINT', stopMethod);
  process.on("exit", stopMethod);
}

const evremapPath = path.resolve(process.env.HOME, '.config/evremap');

const logger = new Logger("CHATTY");
const support = new HyprlandSupport();
const orchestrator = new Orchestrator(evremapPath, logger, support);

prepareProcess(orchestrator);

orchestrator.run()
