/*
 * Copyright 2020 Adobe. All rights reserved.
 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License. You may obtain a copy
 * of the License at http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under
 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
 * OF ANY KIND, either express or implied. See the License for the specific language
 * governing permissions and limitations under the License.
 */
// eslint-disable-next-line max-classes-per-file
import fs from 'fs';
import { createRequire } from 'module';
import chokidar from 'chokidar';
import WebSocket from 'faye-websocket';
import { EventEmitter } from 'events';

const require = createRequire(import.meta.url);

/**
 * Client connection for the live reload server.
 */
class ClientConnection extends EventEmitter {
  static nextId() {
    ClientConnection.counter = (ClientConnection.counter || 0) + 1;
    return `ws${ClientConnection.counter}`;
  }

  constructor(req, socket, head, logger) {
    super();
    this.id = ClientConnection.nextId();
    this.log = logger;
    this.ws = new WebSocket(req, socket, head);
    this.ws.onmessage = this._onMessage.bind(this);
    this.ws.onclose = this._onClose.bind(this);
  }

  _onMessage(event) {
    let data = {};
    try {
      data = JSON.parse(event.data);
    } catch {
      // ignore
    }
    switch (data.command) {
      case 'hello':
        return this._cmdHello(data);
      case 'info':
        return this._cmdInfo(data);
      case 'log':
        return this._cmdLog(data);
      default:
        return {};
    }
  }

  _onClose(event) {
    if (this.ws) {
      this.ws.close();
      this.ws = null;
    }
    this.emit('end', event);
  }

  _cmdHello() {
    this._send({
      command: 'hello',
      protocols: [
        'http://livereload.com/protocols/official-7',
      ],
      serverName: 'aem-simulator',
    });
  }

  _cmdInfo(data) {
    if (data) {
      this.plugins = data.plugins;
      this.url = data.url;
    }
    return { ...data || {}, id: this.id, url: this.url };
  }

  _cmdLog(data) {
    const {
      level = 'log', args = [], url = 'unknown', line,
    } = data;
    const timestamp = new Date().toISOString();
    const location = line ? `${url}:${line}` : url;

    // Format browser logs distinctively
    const prefix = `[Browser:${level}] ${timestamp} ${location}`;

    // Serialize args safely
    const message = args.map((arg) => {
      try {
        return typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg);
      } catch (e) {
        return '[Circular or Complex Object]';
      }
    }).join(' ');

    // Use appropriate log level, preserving this context
    if (this.log[level]) {
      this.log[level](`${prefix} ${message}`);
    } else {
      this.log.info(`${prefix} ${message}`);
    }
  }

  _send(data) {
    if (this.ws) {
      this.ws.send(JSON.stringify(data));
    }
  }

  sendReload(files) {
    files.forEach((file) => {
      this._send({
        command: 'reload',
        path: file,
        liveCSS: true,
        reloadMissingCSS: true,
        liveImg: true,
      });
    }, this);
  }

  sendAlert(message) {
    this._send({
      command: 'alert',
      message,
    });
  }

  close() {
    this._onClose({});
  }
}

/**
 * Live reload file watcher and server.
 */
export default class LiveReload extends EventEmitter {
  constructor(logger) {
    super();
    // file to request mapping
    this._fileMapping = new Map();
    // pending requests by request id
    this._pending = new Map();
    this._logger = logger;

    // client connections
    this._connections = {};
    this._liveReloadJSPath = require.resolve('livereload-js/dist/livereload.js');
    this._forwardBrowserLogs = false;
  }

  get log() {
    return this._logger;
  }

  withForwardBrowserLogs(value) {
    this._forwardBrowserLogs = value;
    return this;
  }

  get forwardBrowserLogs() {
    return this._forwardBrowserLogs;
  }

  startRequest(requestId, pathname) {
    this._pending.set(requestId, {
      pathname,
      files: [],
    });
  }

  endRequest(requestId) {
    const info = this._pending.get(requestId);
    if (!info) {
      this.log.debug('unable to register accessed files. info does not exist: ', requestId);
      return;
    }
    this._pending.delete(requestId);
    this.registerFiles(info.files, info.pathname);
  }

  registerFiles(files, pathName) {
    this._watcher.add(files);
    files.forEach((file) => {
      this._fileMapping.set(file, pathName);
    });
  }

  registerFile(requestId, filePath) {
    const info = this._pending.get(requestId);
    if (info) {
      info.files.push(filePath);
    } else {
      this.log.debug(`unable to register file ${filePath}. info for ${requestId} does not exit.`);
    }
  }

  async init(app, httpServer) {
    this._server = httpServer;
    app.get('/__internal__/livereload.js', this._serveLiveReload.bind(this));
    httpServer.on('upgrade', this._onSvrUpgrade.bind(this));
    httpServer.on('error', this._onSvrError.bind(this));
    httpServer.on('close', this._onSvrClose.bind(this));
    this._initWatcher();
  }

  _initWatcher() {
    let timer = null;
    let modifiedFiles = {};

    this._watcher = chokidar.watch([], {
      ignored: [/(.*\.swx|.*\.swp|.*~)/],
      persistent: true,
      ignoreInitial: true,
    });

    this._watcher.on('all', (eventType, file) => {
      modifiedFiles[file] = true;
      if (timer) {
        clearTimeout(timer);
      }
      // debounce a bit in case several files are changed at once
      timer = setTimeout(async () => {
        timer = null;
        // only proceed if watcher not closed.
        if (this._watcher) {
          const files = Object.keys(modifiedFiles);
          modifiedFiles = {};

          // inform clients
          await this.changed(files);
        }
      }, 100);
    });
  }

  async stop() {
    if (this._watcher) {
      await this._watcher.close();
      delete this._watcher;
    }
    this._onSvrClose();
    this.log.debug('livereload stopped.');
  }

  _onSvrUpgrade(req, socket, head) {
    const cx = new ClientConnection(req, socket, head, this.log);
    this._connections[cx.id] = cx;

    socket.on('error', (e) => {
      if (e.code === 'ECONNRESET' || e.code === 'EBADF') {
        return;
      }
      this._onSvrError(e);
    });

    cx.once('end', () => {
      this.log.debug(`websocket connection closed ${cx.id} (url: ${cx.url})`);
      delete this._connections[cx.id];
    });
  }

  _onSvrClose() {
    Object.values(this._connections).forEach((cx) => {
      try {
        cx.close();
      } catch (e) {
        this.log.error('error closing connection', e);
      }
    }, this);
  }

  _onSvrError(e) {
    this.log.error(e);
  }

  _serveLiveReload(req, res) {
    res.setHeader('content-type', 'application/javascript');
    fs.createReadStream(this._liveReloadJSPath).pipe(res);
  }

  async changed(files) {
    this.log.debug(`changed files: ${files}`);

    // map each file to the registered source
    const sources = new Set();
    files.forEach((file) => {
      const mapping = this._fileMapping.get(file);
      if (mapping) {
        sources.add(mapping);
      }
    });
    const modified = Array.from(sources);
    await this.emit('modified', modified);

    Object.values(this._connections).forEach((cx) => {
      this.log.debug(`reloading client ${cx.id} (url: ${cx.url})`);
      cx.sendReload(modified);
    });
  }

  alert(message) {
    this.log.debug(`alert: ${message}`);
    Object.values(this._connections).forEach((cx) => {
      this.log.debug(`alert client ${cx.id} (url: ${cx.url})`);
      cx.sendAlert(message);
    });
  }
}
