import * as JsPDF from 'jspdf';

export interface IPrintConfig {
  document?: Document;
  window?: Window;
}
export interface ISrcDstPair {
  src: HTMLElement;
  dst: HTMLElement;
}

function getBinaryPromise(url: string): Promise<string> {
  return new Promise<string>((resolve) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url, true);
    xhr.overrideMimeType('text/plain; charset=x-user-defined');
    xhr.onreadystatechange = () => {
      if (xhr.readyState === 4) {
        if (xhr.status === 200) {
          resolve(xhr.responseText);
        } else {
          // reject(xhr.status);
          resolve('');
        }
      }
    };
    xhr.send();
  });
}

function base64Encode(str: string) {
  const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
  const len = str.length;
  const out = [];
  let i = 0;
  let c1;
  let c2;
  let c3;
  while (i < len) {
    c1 = str.charCodeAt(i++) & 0xff;
    if (i === len) {
      out.push(CHARS.charAt(c1 >> 2));
      out.push(CHARS.charAt((c1 & 0x3) << 4));
      out.push('==');
      break;
    }
    c2 = str.charCodeAt(i++);
    if (i === len) {
      out.push(CHARS.charAt(c1 >> 2));
      out.push(CHARS.charAt(((c1 & 0x3) << 4) | ((c2 & 0xF0) >> 4)));
      out.push(CHARS.charAt((c2 & 0xF) << 2));
      out.push('=');
      break;
    }
    c3 = str.charCodeAt(i++);
    out.push(CHARS.charAt(c1 >> 2));
    out.push(CHARS.charAt(((c1 & 0x3) << 4) | ((c2 & 0xF0) >> 4)));
    out.push(CHARS.charAt(((c2 & 0xF) << 2) | ((c3 & 0xC0) >> 6)));
    out.push(CHARS.charAt(c3 & 0x3F));
  }

  return out.join('');
}

export class PrintPDF {
  private static readonly PSEUDO_ELEMENTS: string[] = [
    '::after',
    '::before'
    // '::placeholder'
  ];

  private static readonly IGNORED_PROPERTIES: Set<string> = new Set<string>([
    'animation',
    'animation-delay',
    'animation-direction',
    'animation-duration',
    'animation-fill-mode',
    'animation-iteration',
    'animation-name',
    'animation-play-state: ',
    'animation-timing-function',
    'animation-iteration-count',
    'animation-play-state',
    'transition-delay',
    'transition-duration',
    'transition-property',
    'transition-timing-function',
    'cursor'
  ]);

  private static readonly MIME_TYPES: {} = {
    woff: 'application/font-woff',
    woff2: 'application/font-woff',
    eot: 'application/vnd.ms-fontobject',
    ttf: 'application/font-sfnt',
    svg: 'image/svg+xml'

  };

  private static getMimeType(url: string) {
    const match = /\.([A-z0-9]+?)([?#]+.*)?$/gi.exec(url);
    if (!match) {
      return 'text/html';
    }

    return PrintPDF.MIME_TYPES[match[1].toLowerCase()];
  }

  private static zip<T>(...args: T[][]) {
    return args[0].map(({}: {}, i: number) => args.map((array: T[]) => array[i]));
  }

  private static flatMap<T, E>(elements: T[], callback: (t: T) => E[]): E[] {
    return elements.reduce((ys: E[], x: T) => {
      return ys.concat(callback.call(this, x));
    },                     [] as E[]);
  }

  private serializer: XMLSerializer;
  private doc: Document;
  private win: Window;
  private readonly element: HTMLElement;
  private uniqueCounter: number;
  private defaultsMap: Map<string, Map<string, {}>>;

  constructor(element: HTMLElement, cfg: IPrintConfig = {}) {
    this.serializer = new XMLSerializer();
    this.doc = cfg.document || document;
    this.win = cfg.window || window;
    this.element = element;
    this.uniqueCounter = 0;
    this.defaultsMap = new Map<string, Map<string, {}>>();
  }

  public getDefaultMap(tagName: string): Map<string, {}> {
    if (this.defaultsMap.has(tagName)) {
      return this.defaultsMap.get(tagName);
    }
    const el = this.doc.createElement(tagName);
    const elMap = new Map<string, {}>();
    this.doc.body.appendChild(el);
    const computedStyles = this.win.getComputedStyle(el);
    Array.from(computedStyles).forEach(prop => {
      elMap.set(prop, computedStyles.getPropertyValue(prop));
    });
    el.remove();
    this.defaultsMap.set(tagName, elMap);

    return elMap;
  }

  public getUniqueId(): string {
    this.uniqueCounter += 1;

    return `print-pdf-${this.uniqueCounter}`;
  }

  public traverseNodes(o: HTMLElement, d: HTMLElement, applyFn: (n1: HTMLElement, n2: HTMLElement) => void) {
    const { filter } = Array.prototype;

    applyFn(o, d);
    if (o && d) {
      const srcNodes = filter.call(o.childNodes, (e: {}) => e instanceof HTMLElement);
      const dstNodes = filter.call(d.childNodes, (e: {}) => e instanceof HTMLElement);
      if (srcNodes && dstNodes && srcNodes.length === dstNodes.length) {
        PrintPDF.zip(srcNodes, dstNodes).forEach((v: [HTMLElement, HTMLElement]) =>
          this.traverseNodes(v[0], v[1], applyFn)
        );
      }
    }
  }

  public copyFontFaces(globalStyle: HTMLStyleElement, progressFn: (percent: number, status: string) => void) {
    return new Promise<{}>(resolve => {
      const styleSheets = Array.from(this.doc.styleSheets);
      const cssRules = PrintPDF.flatMap(styleSheets, (s: CSSStyleSheet) => Array.from(s.cssRules));
      const ruleType = CSSRule.FONT_FACE_RULE;
      const ffRules: CSSRule[] = cssRules.filter((r: CSSRule) => r.type === ruleType);

      interface KV {
        key: string;
        value: string;
      }
      const promises: Promise<KV>[] = [];
      let counter = 0;

      ffRules.forEach((r: CSSStyleRule) => {
        const styleProperties = Array.from(r.style);
        styleProperties.forEach((property: string) => {
          const propValue = r.style.getPropertyValue(property);
          const reg = new RegExp(/url\(["']?(.+?)["']?\)/g);
          let exec = reg.exec(propValue);
          while (exec) {
            const match = exec[1];
            const baseUrl = r.parentStyleSheet.href || this.element.ownerDocument.location.href;
            const url = new URL(match, baseUrl).toString();
            const mimeType = PrintPDF.getMimeType(url);
            const promChain = getBinaryPromise(url).then(base64Encode).then(enc => {
                counter++;
                const percent = (counter / promises.length * .20);
                const status = `Loading Font Asset ${counter}/${promises.length}`;
                progressFn(percent, status);

                return { key: url, value: `url("data:${mimeType};base64,${enc}")` };
              }
            );
            promises.push(promChain);
            exec = reg.exec(propValue);
          }
        });
      });

      return Promise.all(promises).then(kvList => {
        const mapResults = new Map<string, string>();
        kvList.forEach(kv => {
          mapResults.set(kv.key, kv.value);
        });

        return mapResults;
      }).then(mapResults => {
        ffRules.forEach((r: CSSStyleRule) => {
          const styleProperties = Array.from(r.style);
          const styleText = styleProperties.map((property: string) => {
            let propValue = r.style.getPropertyValue(property);
            if (/url\(["']?[^)]+["']?\)/.test(propValue)) {
              const baseUrl = r.parentStyleSheet.href || this.element.ownerDocument.location.href;
              propValue = propValue.replace(/url\(["']?(.+?)["']?\)/g, (__: string, match: string) => {
                const url = new URL(match, baseUrl).toString();
                if (mapResults.has(url)) {
                  return mapResults.get(url);
                }
                // Shouldn't happen if we're able to load

                return `url("${match}")`;
              });
            }

            return `\t${property}: ${propValue};`;
          }).join('\n');
          const textNode = this.doc.createTextNode(`\n@font-face {\n${styleText}\n}\n`);
          globalStyle.appendChild(textNode);
          resolve();
        });
      });
    });
  }

  public getCSSText(computedStyle: CSSStyleDeclaration, dst: HTMLElement, selector: string = '') {
    const { filter } = Array.prototype;
    const defaultMap = this.getDefaultMap(dst.tagName);
    const textPairs = [];

    filter.call(computedStyle, (name: string) => !PrintPDF.IGNORED_PROPERTIES.has(name))
      .forEach((propertyName: string) => {
        const propValue = computedStyle.getPropertyValue(propertyName);
        if (selector !== '' || defaultMap.get(propertyName) !== propValue) {
          textPairs.push(`\t${propertyName}: ${propValue};`);
        }
      });
    const cssText = textPairs.join('\n');
    if (!dst.id) {
      dst.id = this.getUniqueId();
      dst.className = `print-cn-${dst.tagName}`;
    }

    return `\n#${dst.id}${selector} {\n${cssText}\n}\n`;
  }

  public toPDF(progressFn: (percentComplete: number, statusMsg: string) => void = () => { return; }): Promise<JsPDF> {
    const element = this.element;
    let copyElement;
    const ceComputedStyle = this.win.getComputedStyle(element);
    let width = element.clientWidth;
    let height = element.clientHeight;
    width += parseInt(ceComputedStyle.getPropertyValue('margin-left'), 10);
    width += parseInt(ceComputedStyle.getPropertyValue('margin-right'), 10);
    height += parseInt(ceComputedStyle.getPropertyValue('margin-top'), 10);
    height += parseInt(ceComputedStyle.getPropertyValue('margin-bottom'), 10);
    const globalStyle = this.doc.createElement('style');
    globalStyle.type = 'text/css';
    const srcDstPairs: ISrcDstPair[] = [];
    const copiedStyles = [];

    return new Promise(resolve => {
      this.win.setTimeout(() => {
        progressFn(0.0, 'Cloning Node');
        copyElement = this.doc.importNode(element, true) as HTMLElement;
        copyElement.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
        resolve();
      },                  100);
    }).then(() => {
      return this.copyFontFaces(globalStyle, progressFn);
    }).then(() => {
      let counter = 0;
      const copySubTaskPercent = .65;
      const nodePromises = [];
      this.traverseNodes(element, copyElement, (src: HTMLElement, dst: HTMLElement) => {
        srcDstPairs.push({ src, dst });
        dst.className = '';
        dst.removeAttribute('id');
        dst.removeAttribute('style');
        counter++;
        const itemNumber = counter;
        const nodePromise = new Promise<number>(resolve => {
          const computedStyle = this.win.getComputedStyle(src);
          const text = this.getCSSText(computedStyle, dst);
          copiedStyles.push(text);
          PrintPDF.PSEUDO_ELEMENTS.forEach((pseudoSelector: string) => {
            const psComputedStyle = this.win.getComputedStyle(src, pseudoSelector);
            const content = psComputedStyle.getPropertyValue('content');
            if (content !== 'none') {
              const psText = this.getCSSText(psComputedStyle, dst, pseudoSelector);
              copiedStyles.push(psText);
            }
          });
          resolve(itemNumber);
        });
        nodePromises.push(nodePromise);
        nodePromise.then(c => {
          return new Promise(resolve => {
            this.win.setTimeout(() => {
              const statusMsg = `Reading Computed Style ${c}/${nodePromises.length}`;
              const percentComplete = (c / nodePromises.length * copySubTaskPercent) + .2;
              progressFn(percentComplete, statusMsg);
              resolve();
            },                  1);
          });

        });
      });

      return Promise.all(nodePromises);
    }).then(() => {
      this.defaultsMap.forEach((innerMap, tagName) => {
        const textPairs = [];
        innerMap.forEach((propValue, propertyName) => {
          textPairs.push(`\t${propertyName}: ${propValue};`);
        });
        const styleText = `\n.print-cn-${tagName} {\n${textPairs.join('\n')}\n}\n`;
        copiedStyles.push(styleText);
      });

      const stylesText = copiedStyles.join('');
      const textNode = this.doc.createTextNode(stylesText);
      globalStyle.appendChild(textNode);
      const promises = srcDstPairs.map((pair: ISrcDstPair) => {
        const { src, dst } = pair;

        return new Promise((resolve) => {
          ['scrollLeft', 'scrollTop', 'value'].forEach((prop: string) => {
            dst[prop] = src[prop];
          });
          resolve();
        });
      });

      return Promise.all(promises);
    }).then(() => {
      progressFn(.85, 'Appending Master StyleSheet');
      copyElement.appendChild(globalStyle);

      return copyElement;
    }).then(copy => {
      progressFn(.87, 'Serializing');

      return this.serializer.serializeToString(copy);
    }).then(serialized => {
      progressFn(.88, 'Encoding to Data URI');
      const encoded = encodeURIComponent(serialized);
      const foreignObject = `<foreignObject width='100%' height='100%'>${encoded}</foreignObject>`;
      const namespace = 'http://www.w3.org/2000/svg';
      const svgMarkup = `<svg xmlns='${namespace}' width='${width}' height='${height}'>${foreignObject}</svg>`;

      return `data:image/svg+xml,${svgMarkup}`;
    }).then(dataUri => {

      return new Promise(resolve => {
        this.win.setTimeout(() => {
          progressFn(.90, 'Creating Canvas');
          const tmpCanvas = this.doc.createElement('canvas');
          tmpCanvas.width = width;
          tmpCanvas.height = height;
          const tmpCtx = tmpCanvas.getContext('2d', { alpha: false });
          tmpCtx.fillStyle = '#ffffff';
          tmpCtx.fillRect(0, 0, width, height);
          const img = new Image();
          img.onload = () =>  {
            progressFn(.95, 'Drawing');
            tmpCtx.drawImage(img, 0, 0, width, height);
            const dataURL = tmpCanvas.toDataURL('image/jpeg', 1.0);
            resolve(dataURL);
          };
          img.src = dataUri;
        },                  10);
      });

    }).then(dataURL => {
      progressFn(.98, 'Creating PDF');
      const orientation = (width > height) ? 'l' : 'p';
      const pdf = new JsPDF(orientation, 'pt', [width, height]);
      const w = Math.floor(pdf.internal.pageSize.getWidth());
      const h = Math.floor(pdf.internal.pageSize.getHeight());
      pdf.addImage(dataURL, 'PNG', 0, 0, w, h);
      progressFn(1.0, 'Done');

      return pdf;
    });
  }

  public printPDF(progressFn?: (percentComplete: number, statusMsg: string) => void): Promise<JsPDF> {
    return this.toPDF(progressFn).then((pdf: JsPDF) => {
      const fileURL = URL.createObjectURL(pdf.output('blob'));
      const win = window.open(fileURL, '_blank');
      win.focus();

      return pdf;
    });
  }
}
