import {
  BlockMapDataHolder,
  createHttpError,
  DigestTransform,
  HttpExecutor,
  configureRequestUrl,
  configureRequestOptions
} from "builder-util-runtime";
import { BlockMap } from "builder-util-runtime/out/blockMapApi";
import { close, open } from "fs-extra";
import { createWriteStream } from "fs";
import { OutgoingHttpHeaders, RequestOptions } from "http";
import { Logger, DOWNLOAD_PROGRESS } from "../main";
import { copyData } from "./DataSplitter";
import { URL } from "url";
import {
  computeOperations,
  Operation,
  OperationKind
} from "./downloadPlanBuilder";
import {
  checkIsRangesSupported,
  executeTasksUsingMultipleRangeRequests
} from "./multipleRangeDownloader";

export interface DifferentialDownloaderOptions {
  readonly oldFile: string;
  readonly newUrl: URL;
  readonly logger: Logger;
  readonly newFile: string;

  readonly requestHeaders: OutgoingHttpHeaders | null;

  readonly isUseMultipleRangeRequest?: boolean;
}

export abstract class DifferentialDownloader {
  fileMetadataBuffer: Buffer | null = null;

  private readonly logger: Logger;

  // noinspection TypeScriptAbstractClassConstructorCanBeMadeProtected
  constructor(
    protected readonly blockAwareFileInfo: BlockMapDataHolder,
    readonly httpExecutor: HttpExecutor<any>,
    readonly options: DifferentialDownloaderOptions
  ) {
    this.logger = options.logger;
  }

  createRequestOptions(): RequestOptions {
    const result = {
      headers: {
        ...this.options.requestHeaders,
        accept: "*/*"
      }
    };
    configureRequestUrl(this.options.newUrl, result);
    // user-agent, cache-control and other common options
    configureRequestOptions(result);
    return result;
  }

  protected doDownload(
    oldBlockMap: BlockMap,
    newBlockMap: BlockMap,
    emit: Function
  ): Promise<any> {
    // we don't check other metadata like compressionMethod - generic check that it is make sense to differentially update is suitable for it
    if (oldBlockMap.version !== newBlockMap.version) {
      throw new Error(
        `version is different (${oldBlockMap.version} - ${newBlockMap.version}), full download is required`
      );
    }

    const logger = this.logger;
    const operations = computeOperations(oldBlockMap, newBlockMap, logger);
    if (logger.debug != null) {
      logger.debug(JSON.stringify(operations, null, 2));
    }

    let downloadSize = 0;
    let copySize = 0;
    for (const operation of operations) {
      const length = operation.end - operation.start;
      if (operation.kind === OperationKind.DOWNLOAD) {
        downloadSize += length;
      } else {
        copySize += length;
      }
    }

    const newSize = this.blockAwareFileInfo.size;
    if (
      downloadSize +
        copySize +
        (this.fileMetadataBuffer == null
          ? 0
          : this.fileMetadataBuffer.length) !==
      newSize
    ) {
      throw new Error(
        `Internal error, size mismatch: downloadSize: ${downloadSize}, copySize: ${copySize}, newSize: ${newSize}`
      );
    }

    logger.info(
      `Full: ${formatBytes(newSize)}, To download: ${formatBytes(
        downloadSize
      )} (${Math.round(downloadSize / (newSize / 100))}%)`
    );

    return this.downloadFile(operations, emit);
  }

  private downloadFile(tasks: Array<Operation>, emit: Function): Promise<any> {
    const fdList: Array<OpenedFile> = [];
    const closeFiles = (): Promise<Array<void>> => {
      return Promise.all(
        fdList.map(openedFile => {
          return close(openedFile.descriptor).catch(e => {
            this.logger.error(`cannot close file "${openedFile.path}": ${e}`);
          });
        })
      );
    };
    return this.doDownloadFile(tasks, fdList, emit)
      .then(closeFiles)
      .catch(e => {
        // then must be after catch here (since then always throws error)
        return closeFiles()
          .catch(closeFilesError => {
            // closeFiles never throw error, but just to be sure
            try {
              this.logger.error(`cannot close files: ${closeFilesError}`);
            } catch (errorOnLog) {
              try {
                console.error(errorOnLog);
              } catch (ignored) {
                // ok, give up and ignore error
              }
            }
            throw e;
          })
          .then(() => {
            throw e;
          });
      });
  }

  private async doDownloadFile(
    tasks: Array<Operation>,
    fdList: Array<OpenedFile>,
    emit: Function
  ): Promise<any> {
    let oldFileFd: number;
    oldFileFd = await open(this.options.oldFile, "r");
    fdList.push({ descriptor: oldFileFd, path: this.options.oldFile });
    const newFileFd = await open(this.options.newFile, "w");
    fdList.push({ descriptor: newFileFd, path: this.options.newFile });
    const fileOut = createWriteStream(this.options.newFile, {
      fd: newFileFd
    });
    await new Promise((resolve, reject) => {
      const streams: Array<any> = [];
      const digestTransform = new DigestTransform(
        this.blockAwareFileInfo.sha512
      );
      // to simply debug, do manual validation to allow file to be fully written
      digestTransform.isValidateOnEnd = false;
      streams.push(digestTransform);

      // noinspection JSArrowFunctionCanBeReplacedWithShorthand
      fileOut.on("finish", () => {
        (fileOut.close as any)(() => {
          // remove from fd list because closed successfully
          fdList.splice(1, 1);
          try {
            digestTransform.validate();
          } catch (e) {
            reject(e);
            return;
          }

          resolve();
        });
      });

      streams.push(fileOut);

      let lastStream = null;
      for (const stream of streams) {
        stream.on("error", reject);
        if (lastStream == null) {
          lastStream = stream;
        } else {
          lastStream = lastStream.pipe(stream);
        }
      }

      const firstStream = streams[0];
      // TASK - use useMultipleRangeRequest property from package.json
      let w: any;
      if (this.options.isUseMultipleRangeRequest) {
        w = executeTasksUsingMultipleRangeRequests(
          this,
          tasks,
          firstStream,
          oldFileFd,
          reject
        );
        w(0);
        return;
      }

      let downloadOperationCount = 0;
      let actualUrl: string | null = null;
      this.logger.info(`Differential download: ${this.options.newUrl}`);

      const requestOptions = this.createRequestOptions();
      (requestOptions as any).redirect = "manual";

      w = (index: number): void => {
        if (index >= tasks.length) {
          if (this.fileMetadataBuffer != null) {
            firstStream.write(this.fileMetadataBuffer);
          }
          firstStream.end();
          return;
        }

        const operation = tasks[index++];
        if (operation.kind === OperationKind.COPY) {
          copyData(operation, firstStream, oldFileFd, reject, () => w(index));
          return;
        }

        const range = `bytes=${operation.start}-${operation.end - 1}`;
        requestOptions.headers!!.range = range;

        const debug = this.logger.debug;
        if (debug != null) {
          debug(`download range: ${range}`);
          try {
            emit(DOWNLOAD_PROGRESS, {
              transferred: index,
              total: tasks.length,
              percent: (index / tasks.length) * 100
            });
          } catch (e) {
            console.log(e);
          }
        }

        const request = this.httpExecutor.createRequest(
          requestOptions,
          response => {
            // Electron net handles redirects automatically, our NodeJS test server doesn't use redirects - so, we don't check 3xx codes.
            if (response.statusCode >= 400) {
              reject(createHttpError(response));
            }

            response.pipe(firstStream, {
              end: false
            });
            response.once("end", () => {
              if (++downloadOperationCount === 100) {
                downloadOperationCount = 0;
                setTimeout(() => w(index), 1000);
              } else {
                w(index);
              }
            });
          }
        );
        request.on(
          "redirect",
          (statusCode: number, method: string, redirectUrl: string) => {
            this.logger.info(`Redirect to ${removeQuery(redirectUrl)}`);
            actualUrl = redirectUrl;
            configureRequestUrl(new URL(actualUrl), requestOptions);
            request.followRedirect();
          }
        );
        this.httpExecutor.addErrorAndTimeoutHandlers(request, reject);
        request.end();
      };

      w(0);
    });
  }

  protected async readRemoteBytes(
    start: number,
    endInclusive: number
  ): Promise<Buffer> {
    const buffer = Buffer.allocUnsafe(endInclusive + 1 - start);
    const requestOptions = this.createRequestOptions();
    requestOptions.headers!!.range = `bytes=${start}-${endInclusive}`;
    let position = 0;
    await this.request(requestOptions, chunk => {
      chunk.copy(buffer, position);
      position += chunk.length;
    });

    if (position !== buffer.length) {
      throw new Error(
        `Received data length ${position} is not equal to expected ${buffer.length}`
      );
    }
    return buffer;
  }

  private request(
    requestOptions: RequestOptions,
    dataHandler: (chunk: Buffer) => void
  ): Promise<void> {
    return new Promise((resolve, reject) => {
      const request = this.httpExecutor.createRequest(
        requestOptions,
        response => {
          if (!checkIsRangesSupported(response, reject)) {
            return;
          }

          response.on("data", dataHandler);
          response.on("end", () => resolve());
        }
      );
      this.httpExecutor.addErrorAndTimeoutHandlers(request, reject);
      request.end();
    });
  }
}

function formatBytes(value: number, symbol = " KB"): string {
  return (
    new Intl.NumberFormat("en").format((value / 1024).toFixed(2) as any) +
    symbol
  );
}

// safety
function removeQuery(url: string): string {
  const index = url.indexOf("?");
  return index < 0 ? url : url.substring(0, index);
}

interface OpenedFile {
  readonly descriptor: number;
  readonly path: string;
}