// Copyright 2019-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only

import { createReadStream, statSync } from 'fs';
import { createServer, IncomingMessage, Server, ServerResponse } from 'http';
import { AddressInfo } from 'net';
import { dirname } from 'path';

import { v4 as getGuid } from 'uuid';
import { app, autoUpdater, BrowserWindow } from 'electron';
import { get as getFromConfig } from 'config';
import { gt } from 'semver';
import got from 'got';

import {
  checkForUpdates,
  deleteTempDir,
  downloadUpdate,
  getAutoDownloadUpdateSetting,
  getPrintableError,
  setUpdateListener,
  UpdaterInterface,
} from './common';
import { LoggerType } from '../types/Logging';
import { hexToBinary, verifySignature } from './signature';
import { markShouldQuit } from '../../app/window_state';
import { DialogType } from '../types/Dialogs';

const SECOND = 1000;
const MINUTE = SECOND * 60;
const INTERVAL = MINUTE * 30;

export async function start(
  getMainWindow: () => BrowserWindow,
  logger: LoggerType
): Promise<UpdaterInterface> {
  logger.info('macos/start: starting checks...');

  loggerForQuitHandler = logger;
  app.once('quit', quitHandler);

  setInterval(async () => {
    try {
      await checkForUpdatesMaybeInstall(getMainWindow, logger);
    } catch (error) {
      logger.error(`macos/start: ${getPrintableError(error)}`);
    }
  }, INTERVAL);

  await checkForUpdatesMaybeInstall(getMainWindow, logger);

  return {
    async force(): Promise<void> {
      return checkForUpdatesMaybeInstall(getMainWindow, logger, true);
    },
  };
}

let fileName: string;
let version: string;
let updateFilePath: string;
let loggerForQuitHandler: LoggerType;

async function checkForUpdatesMaybeInstall(
  getMainWindow: () => BrowserWindow,
  logger: LoggerType,
  force = false
) {
  logger.info('checkForUpdatesMaybeInstall: checking for update...');
  const result = await checkForUpdates(logger, force);
  if (!result) {
    return;
  }

  const { fileName: newFileName, version: newVersion } = result;

  if (fileName !== newFileName || !version || gt(newVersion, version)) {
    const autoDownloadUpdates = await getAutoDownloadUpdateSetting(
      getMainWindow()
    );
    if (!autoDownloadUpdates) {
      setUpdateListener(async () => {
        logger.info(
          'performUpdate: have not downloaded update, going to download'
        );
        await downloadAndInstall(
          newFileName,
          newVersion,
          getMainWindow,
          logger,
          true
        );
      });
      getMainWindow().webContents.send(
        'show-update-dialog',
        DialogType.DownloadReady,
        {
          downloadSize: result.size,
          version: result.version,
        }
      );
      return;
    }
    await downloadAndInstall(newFileName, newVersion, getMainWindow, logger);
  }
}

async function downloadAndInstall(
  newFileName: string,
  newVersion: string,
  getMainWindow: () => BrowserWindow,
  logger: LoggerType,
  updateOnProgress?: boolean
) {
  try {
    const oldFileName = fileName;
    const oldVersion = version;

    deleteCache(updateFilePath, logger);
    fileName = newFileName;
    version = newVersion;
    try {
      updateFilePath = await downloadUpdate(
        fileName,
        logger,
        updateOnProgress ? getMainWindow() : undefined
      );
    } catch (error) {
      // Restore state in case of download error
      fileName = oldFileName;
      version = oldVersion;
      throw error;
    }

    if (!updateFilePath) {
      logger.info('downloadAndInstall: no update file path. Skipping!');
      return;
    }

    const publicKey = hexToBinary(getFromConfig('updatesPublicKey'));
    const verified = await verifySignature(updateFilePath, version, publicKey);
    if (!verified) {
      // Note: We don't delete the cache here, because we don't want to continually
      //   re-download the broken release. We will download it only once per launch.
      throw new Error(
        `downloadAndInstall: Downloaded update did not pass signature verification (version: '${version}'; fileName: '${fileName}')`
      );
    }

    try {
      await handToAutoUpdate(updateFilePath, logger);
    } catch (error) {
      const readOnly = 'Cannot update while running on a read-only volume';
      const message: string = error.message || '';
      if (message.includes(readOnly)) {
        logger.info('downloadAndInstall: showing read-only dialog...');
        getMainWindow().webContents.send(
          'show-update-dialog',
          DialogType.MacOS_Read_Only
        );
      } else {
        logger.info(
          'downloadAndInstall: showing general update failure dialog...'
        );
        getMainWindow().webContents.send(
          'show-update-dialog',
          DialogType.Cannot_Update
        );
      }

      throw error;
    }

    // At this point, closing the app will cause the update to be installed automatically
    //   because Squirrel has cached the update file and will do the right thing.
    logger.info('downloadAndInstall: showing update dialog...');

    setUpdateListener(() => {
      logger.info('performUpdate: calling quitAndInstall...');
      markShouldQuit();
      autoUpdater.quitAndInstall();
    });
    getMainWindow().webContents.send('show-update-dialog', DialogType.Update, {
      version,
    });
  } catch (error) {
    logger.error(`downloadAndInstall: ${getPrintableError(error)}`);
  }
}

function quitHandler() {
  deleteCache(updateFilePath, loggerForQuitHandler);
}

// Helpers

function deleteCache(filePath: string | null, logger: LoggerType) {
  if (filePath) {
    const tempDir = dirname(filePath);
    deleteTempDir(tempDir).catch(error => {
      logger.error(`quitHandler: ${getPrintableError(error)}`);
    });
  }
}

async function handToAutoUpdate(
  filePath: string,
  logger: LoggerType
): Promise<void> {
  return new Promise((resolve, reject) => {
    const token = getGuid();
    const updateFileUrl = generateFileUrl();
    const server = createServer();
    let serverUrl: string;

    server.on('error', (error: Error) => {
      logger.error(`handToAutoUpdate: ${getPrintableError(error)}`);
      shutdown(server, logger);
      reject(error);
    });

    server.on(
      'request',
      (request: IncomingMessage, response: ServerResponse) => {
        const { url } = request;

        if (url === '/') {
          const absoluteUrl = `${serverUrl}${updateFileUrl}`;
          writeJSONResponse(absoluteUrl, response);

          return;
        }

        if (url === '/token') {
          writeTokenResponse(token, response);

          return;
        }

        if (!url || !url.startsWith(updateFileUrl)) {
          write404(url, response, logger);

          return;
        }

        pipeUpdateToSquirrel(filePath, server, response, logger, reject);
      }
    );

    server.listen(0, '127.0.0.1', async () => {
      try {
        serverUrl = getServerUrl(server);

        autoUpdater.on('error', (...args) => {
          logger.error('autoUpdater: error', ...args.map(getPrintableError));

          const [error] = args;
          reject(error);
        });
        autoUpdater.on('update-downloaded', () => {
          logger.info('autoUpdater: update-downloaded event fired');
          shutdown(server, logger);
          resolve();
        });

        const response = await got.get(`${serverUrl}/token`);
        if (JSON.parse(response.body).token !== token) {
          throw new Error(
            'autoUpdater: did not receive token back from updates server'
          );
        }

        autoUpdater.setFeedURL({
          url: serverUrl,
          headers: { 'Cache-Control': 'no-cache' },
        });
        autoUpdater.checkForUpdates();
      } catch (error) {
        reject(error);
      }
    });
  });
}

function pipeUpdateToSquirrel(
  filePath: string,
  server: Server,
  response: ServerResponse,
  logger: LoggerType,
  reject: (error: Error) => void
) {
  const updateFileSize = getFileSize(filePath);
  const readStream = createReadStream(filePath);

  response.on('error', (error: Error) => {
    logger.error(
      `pipeUpdateToSquirrel: update file download request had an error ${getPrintableError(
        error
      )}`
    );
    shutdown(server, logger);
    reject(error);
  });

  readStream.on('error', (error: Error) => {
    logger.error(
      `pipeUpdateToSquirrel: read stream error response: ${getPrintableError(
        error
      )}`
    );
    shutdown(server, logger, response);
    reject(error);
  });

  response.writeHead(200, {
    'Content-Type': 'application/zip',
    'Content-Length': updateFileSize,
  });

  readStream.pipe(response);
}

function writeJSONResponse(url: string, response: ServerResponse) {
  const data = Buffer.from(
    JSON.stringify({
      url,
    })
  );
  response.writeHead(200, {
    'Content-Type': 'application/json',
    'Content-Length': data.byteLength,
  });
  response.end(data);
}

function writeTokenResponse(token: string, response: ServerResponse) {
  const data = Buffer.from(
    JSON.stringify({
      token,
    })
  );
  response.writeHead(200, {
    'Content-Type': 'application/json',
    'Content-Length': data.byteLength,
  });
  response.end(data);
}

function write404(
  url: string | undefined,
  response: ServerResponse,
  logger: LoggerType
) {
  logger.error(`write404: Squirrel requested unexpected url '${url}'`);
  response.writeHead(404);
  response.end();
}

function getServerUrl(server: Server) {
  const address = server.address() as AddressInfo;

  return `http://127.0.0.1:${address.port}`;
}
function generateFileUrl(): string {
  return `/${getGuid()}.zip`;
}

function getFileSize(targetPath: string): number {
  const { size } = statSync(targetPath);

  return size;
}

function shutdown(
  server: Server,
  logger: LoggerType,
  response?: ServerResponse
) {
  try {
    if (server) {
      server.close();
    }
  } catch (error) {
    logger.error(`shutdown: Error closing server ${getPrintableError(error)}`);
  }

  try {
    if (response) {
      response.end();
    }
  } catch (endError) {
    logger.error(
      `shutdown: couldn't end response ${getPrintableError(endError)}`
    );
  }
}