import fs from 'fs';
import { DataField } from '../openapi-parser';
import { deepOverride } from '../utils/obj-utils';
import { EFooterOptions } from '../input-args';
const PDFDocument = require('pdfkit');

class Font {
  constructor(
    public face: string,
    public style?: string
  ) {}
}

interface ITextStyle {
  font?: Font;
  fontSize?: number;
  fillColor?: string;
  leftMargin?: number;
  lineGap?: number;
}

interface ITextOptions {
  continued?: boolean;
  lineBreak?: boolean;
  underline?: boolean;
  destination?: string;
  goTo?: string | null;
  indent?: number;
  align?: string;
  x?: number;
  y?: number;
};

export class PdfWriter {

  // configurable PDF style
  style = {
    color: {
      main: '#333333',
      secondary: '#6B7B8E',
      highlight: '#8A3324',
      headers: '#2A4D69',
      subHeaders: '#4B86B4',
  
      getMethod: '#4A90E2',
      putMethod: '#6B8E23',
      postMethod: '#D87F0A',
      patchMethod: '#C2A000',
      deleteMethod: '#D0021B',
      otherMethods: '#2A4D69'
    },
    font: {
      baseSize: 10,
      main: {
        norm: new Font('Helvetica'),
        bold: new Font('Helvetica-Bold'),
        italic: new Font('Helvetica-Oblique'),
      },
      mono: { 
        norm: new Font('Courier'),
        bold: new Font('Courier-Bold'),
        italic: new Font('Courier-Oblique')
      }
    },
    format: {
      indentStep: 12,
      horizontalMargin: 70,
      verticalMargin: 50
    }
  };

  private doc;

  private currentSectionName = '';

  private pageNumber = 0;
  private pageHeaderNodes: string[] = []; 

  private docOutlines: any[] = [];
  private styleStack: ITextStyle[] = [];

  private headerGap = 0.7;
  private paraGap = 0.5;

  private baseStyle: ITextStyle;

  constructor(
    outputFile?: string,
    private options?: {
      style?: any,
      footerContent?: string
    }
  ) {
    if (options?.style) {
      this.style = deepOverride(this.style, options?.style);
    }

    this.baseStyle = {
      font: this.style.font.main.norm,
      fontSize: this.style.font.baseSize,
      fillColor: this.style.color.main,
      leftMargin: this.style.format.horizontalMargin,
      lineGap: 0,
    };

    this.doc = new PDFDocument({ 
      bufferPages: true, 
      autoFirstPage: false, 
      margins:  {
        left: this.style.format.horizontalMargin,
        right: this.style.format.horizontalMargin,
        top: this.style.format.verticalMargin,
        bottom: this.style.format.verticalMargin
      }
    });

    if (outputFile) {
      const writeStream = fs.createWriteStream(outputFile);
      this.doc.pipe(writeStream);
    }

    // NOTE: it is impossible to edit pages in pageAdded as it is sometimes invoked after one text already placed on the page
    // which produces unexpected formatting issues
    this.doc.on('pageAdded', () => {
      // debugLog(`Page: ${this.pageNumber}, headerNote: ${this.currentSectionName}`);
      this.pageNumber++;
      this.pageHeaderNodes.push(this.currentSectionName);
    });
  }

  addTitlePage(title: string, subtitle?: string, date?: string) {
    // NOTE: font sizes on the title screen don't depent on base fontSize
    this.doc.addPage();
    this.doc.y = this.doc.page.height * 0.3;
    this.text(title, { font: this.style.font.main.bold, fontSize: 20 }, { align: 'center' });
    
    if (subtitle) {
      this.lineBreak(this.paraGap);
      this.text(subtitle, { fontSize: 14 }, { align: 'center' });
    }
    
    if (date) {
      this.lineBreak(3);
      this.text(date, { fontSize: 12, fillColor: this.style.color.secondary }, { align: 'center' });
    }
  }

  newSection(name: string) {
    this.currentSectionName = name;
    this.resetStyle();
    this.doc.addPage();
  }

  indentStart(): PdfWriter {
    this.pushStyle({ leftMargin: this.style.format.indentStep });
    return this;
  }

  indentEnd(): PdfWriter {
    this.popStyle();
    return this;
  }

  nextLine(): PdfWriter {
    return this.text('').lineBreak();
  }

  lineBreak(n: number = 1): PdfWriter {
    this.doc.moveDown(n);
    return this;
  }

  paraBreak(): PdfWriter {
    return this.lineBreak(this.paraGap);
  }

  text(str: string, style?: ITextStyle, options?: ITextOptions): PdfWriter {
    if (style && Object.keys(style).length > 0) {
      this.withStyle(style, () => this.textImpl(str, options));
    } else {
      this.textImpl(str, options);
    }
    return this;
  }

  textRef(str: string, anchor?: string) {
    this.text(str, { fillColor: this.style.color.highlight }, {
      goTo: anchor ?? undefined, 
      underline: anchor ? true : false
    });
  }

  private textImpl(str: string, options?: ITextOptions): PdfWriter {
    const style = this.currentStyle();
    const styledOpt = { lineGap: style.lineGap, ...options };
    const absolutePos = styledOpt.x !== undefined || styledOpt.y !== undefined;

    if (absolutePos) {
      this.doc.text(str, styledOpt.x ?? this.doc.x, styledOpt.y ?? this.doc.y, styledOpt);
    } else {
      this.doc.text(str, styledOpt);
    }
    return this;
  }

  header(level: number, str: string, anchor?: string) {
    this.withStyle({ fillColor: this.style.color.headers, font: this.style.font.main.bold, fontSize: this.style.font.baseSize + 4 - level * 2 }, () => {
      this.text(str, {}, { destination: anchor });
      this.lineBreak(this.headerGap);
    });
    this.addOutline(level, str);
  }

  apiHeader(method: string, endpoint: string, headerLevel: number) {
    const fontSize = this.style.font.baseSize + 2;

    const colorByMethod: Record<string, string> = {
      'get': this.style.color.getMethod,
      'put': this.style.color.putMethod,
      'post': this.style.color.postMethod,
      'patch': this.style.color.patchMethod,
      'delete': this.style.color.deleteMethod,
    };
    
    this.withStyle({ font: this.style.font.main.bold, fontSize }, () => {
      const width = this.doc.widthOfString(method);
      const height = this.doc.heightOfString(method);
      const color = colorByMethod[method.toLowerCase()] ?? this.style.color.otherMethods;

      // bugfix: make sure we are already on a new page if needed - to dray rect correctly 
      this.text(method, {}, { continued: true });
      this.text(' ');
      this.doc.moveUp();

      this.doc
        .lineJoin('round').lineWidth(4)
        .rect(this.doc.x, this.doc.y - fontSize / 4, width, height)
        .fillAndStroke(color, color);
      
      this.text(method, { fillColor: 'white' }, { continued: true });
      this.text(`  ${endpoint}`, { fillColor: this.style.color.headers });
      this.lineBreak(this.headerGap);
    });
    this.addOutline(headerLevel, `${method} ${endpoint}`);
  }

  private addOutline(level: number, str: string) {
    let newOutline;
    const outlinesLen = this.docOutlines.length;
    let levelError = false;

    // debugLog(`header: level=${level}, text="${text}"`);

    if (level === 0) {
      newOutline = this.doc.outline.addItem(str);
    } else if (level > 0 && level <= outlinesLen) {
      newOutline = this.docOutlines[level - 1].addItem(str);
    } else {
      levelError = true;
    }
    
    if (!levelError) {
      if (level === outlinesLen) {
        this.docOutlines.push(newOutline);
      } else if (level < outlinesLen) {
        this.docOutlines[level] = newOutline;
        this.docOutlines.splice(level + 1); // remore remainings
      } else if (level > outlinesLen) {
        levelError = true;
      }
    }

    if (levelError) {
      throw new Error(`A header can only be nested inside headers with level - 1. level=${level}, previousLevel=${outlinesLen-1}`);
    }
  }

  subHeader(str: string) {
    this.withStyle({ fillColor: this.style.color.subHeaders, font: this.style.font.main.bold, fontSize: this.style.font.baseSize }, () => {
      this.text(str);
      this.lineBreak(this.headerGap);
    });
  }

  para(str: string): PdfWriter {
    this.text(str);
    this.lineBreak(this.paraGap);
    return this;
  }

  description(str: string) {
    this.text(str, { fillColor: this.style.color.secondary });
    this.lineBreak(this.paraGap);
  }

  dataFields(dataFields: DataField[]) {
    const origX = this.doc.x;

    dataFields.forEach((field) => {
      const fieldName = `${field.name}${(field.required ?? true) ? '':'?'}`
      const fieldType = field.type?.text ? `${field.type?.text};`  : undefined;
      this.text(fieldName, {}, { continued: fieldType ? true : false });

      if (fieldType) {
        this.text(': ', {}, { continued: true });
        this.textRef(fieldType, field.type?.anchor);
      }

      if (field.description) {
        this.doc.moveUp();
        let nameAndType = fieldName + (fieldType ? `: ${fieldType}` : '');
        this.text(`  // ${field.description}`, { fillColor: this.style.color.secondary }, { 
          x: origX + this.style.format.indentStep, 
          indent: this.doc.widthOfString(nameAndType) - this.style.format.indentStep 
        });
      }
      this.doc.x = origX;
    });
  }

  contentType(contentType: string, options?: { lineContinues?: boolean }) {
    this.text('Content: ', {}, { continued: true });
    this.text(contentType, { fillColor: this.style.color.highlight }, { continued: options?.lineContinues });
  }

  schemaType(typeName: string) {
    this.text('Type: ', {}, { continued: true });
    this.text(typeName ?? '-', { fillColor: this.style.color.highlight });
    this.lineBreak(this.paraGap);
  }

  objectSchema(dataFields: DataField[]) {
    this.text('{').indentStart();
    this.dataFields(dataFields);
    this.indentEnd().text('}');
  }

  example(name: string, body: string) {
    this.text(`Example "${name}":`, { font: this.style.font.main.bold });
    this.lineBreak(this.paraGap);
    this.text(body, { fillColor: this.style.color.secondary, fontSize: this.style.font.baseSize - 2, font: this.style.font.mono.bold });
    this.lineBreak(this.paraGap);
  }

  enumValues(values: string[]) {
    this.text('Values: ');
    this.doc.moveUp();
    const nextLineIndent = this.doc.x + this.style.format.indentStep;
    const indent = this.doc.widthOfString('Values: ') - this.style.format.indentStep;

    this.withStyle({ fillColor: this.style.color.highlight }, () => {
      values.forEach((value, index, array) => {
        const str = (index < array.length - 1) ? `${value}, ` : value;
        const continued = (index < array.length - 1) ? true : false;
        if (index === 0) {
          this.text(str, {}, { x: nextLineIndent, indent, continued });
        } else {
          this.text(str, {}, { continued });
        }
      });
    });
  }

  finish() {
    const doc = this.doc;
    const footerOptions = this.options?.footerContent?.trim().split(/\s+/);
    const showPageNumbers = footerOptions?.includes(EFooterOptions.pageNumber);

    // Add headers and footers to all pages
    let pages = doc.bufferedPageRange();

    for (let i = 0; i < pages.count; i++) {
      doc.switchToPage(i);
      const origTop = this.doc.page.margins.top;
      const origBottom = this.doc.page.margins.bottom;
      doc.page.margins.top = 0;
      doc.page.margins.bottom = 0;

      if (i > 0) {
        this.withStyle({ fontSize: 9, fillColor: this.style.color.secondary }, () => {
          if (this.pageHeaderNodes[i]) {
            this.text(this.pageHeaderNodes[i], {}, { y: origTop / 2, align: 'right' });
          }
          if (showPageNumbers) {
            this.text(`Page ${i} / ${pages.count - 1}`, {}, { y: this.doc.page.height - origBottom / 2, align: 'right' });
          }
        });
      }
      
      doc.page.margins.top = origTop;
      doc.page.margins.bottom = origBottom;
    }

    doc.end();
  }

  private setStyle(style: ITextStyle) {
    this.doc.font(style.font!.face, style.font?.style || undefined).fontSize(style.fontSize).fillColor(style.fillColor);
  }

  private resetStyle() {
    this.styleStack = [];
    this.setStyle(this.baseStyle);
  }

  private withStyle(style: ITextStyle, fn: (style: ITextStyle) => void) {
    const newStyle = this.pushStyle(style);
    fn(newStyle);
    this.popStyle();
  }

  private pushStyle(style: ITextStyle): ITextStyle {
    const mergedStyle = { ...this.currentStyle(), ...style };
    mergedStyle.leftMargin = (this.currentStyle().leftMargin ?? 0) + (style.leftMargin ?? 0); // nested indent
    this.setStyle(mergedStyle);
    this.doc.x = mergedStyle.leftMargin;
    this.styleStack.push(mergedStyle);
    return mergedStyle;
  }

  private popStyle(): ITextStyle {
    this.styleStack.pop();
    const prevStyle = this.currentStyle();
    this.setStyle(prevStyle);
    this.doc.x = prevStyle.leftMargin;
    return prevStyle;
  }

  private currentStyle(): ITextStyle {
    return (this.styleStack.length > 0)
      ? this.styleStack[this.styleStack.length-1]
      : this.baseStyle;
  }
}
