export type DistinguisherParser<T> = (distinguisher: string) => T;
export type DocumentParser<T> = (document: string[]) => T;
export type UnionParser<T> = (distinguisher: string, document: string[]) => T;
export type IdvConverter<T> = (idv: Idv) => T;

const LEADING_WHITESPACE = /^([ \t]+)/;
const ENTRY = /^(.+?):\s*(.*)/;

export class Idv {
  collections: Map<string, undefined | [string, string[]][]> = new Map();

  public static parse(input: string): Idv {
    const idv = new Idv();
    return idv.import(input);
  }
  public static parseLines(input: string[]): Idv {
    const idv = new Idv();
    return idv.importLines(input);
  }
  public import(input: string): Idv {
    const lines = input.split("\n").map((line) => line.trimEnd());
    return this.importLines(lines);
  }
  public importLines(input: string[]): Idv {
    let currentDocument: string[] | undefined = undefined;
    let currentIndent: string | undefined = undefined;
    let bufferedBlankLines: string[] = [];

    input.forEach((line) => {
      const indent = LEADING_WHITESPACE.exec(line)?.[1];
      if (indent) {
        if (currentDocument == undefined) {
          throw new Error("Indented document found before an entry");
        }
        if (currentIndent == undefined) {
          currentIndent = indent;
        }
        if (line.startsWith(currentIndent)) {
          currentDocument.push(...bufferedBlankLines);
          bufferedBlankLines = [];
          currentDocument.push(line.substring(currentIndent.length));
        } else {
          throw new Error(
            "Inconsistent indentation- line indented less than the first line of its document"
          );
        }
      } else if (line == "") {
        bufferedBlankLines.push("");
      } else if (line[0] == "#") {
        // skip
      } else {
        const matches = ENTRY.exec(line);
        if (matches) {
          const [, collection, distinguisher] = matches;

          if (!this.collections.has(collection)) {
            this.collections.set(collection, []);
          }

          currentDocument = [];
          currentIndent = undefined;
          // TODO: implement backslash escaping in the distinguisher
          this.collections
            .get(collection)!
            .push([distinguisher, currentDocument]);
        } else {
          throw new Error("Failed to parse a property");
        }
      }
    });

    return this;
  }

  public getProperty<T>(name: string, parse: UnionParser<T>): T | undefined {
    const firstEntry = this.collections.get(name)?.[0];
    return firstEntry && parse(firstEntry[0], firstEntry[1]);
  }

  public getList<T>(name: string, parse: UnionParser<T>): T[] {
    return (this.collections.get(name) ?? []).map(([distinguisher, document]) =>
      parse(distinguisher, document)
    );
  }

  public getMap<T>(
    name: string,
    parseDocument: DocumentParser<T>
  ): Record<string, T> {
    return Object.fromEntries(
      (this.collections.get(name) ?? []).map(([distinguisher, document]) => [
        distinguisher,
        parseDocument(document),
      ])
    );
  }

  public getMergedMap<T>(
    name: string,
    convertIdv: IdvConverter<T>
  ): Record<string, T> {
    const idvMap: Map<string, Idv> = new Map();

    (this.collections.get(name) ?? []).forEach(([distinguisher, document]) => {
      let idv = idvMap.get(distinguisher);
      if (idv == undefined) {
        idvMap.set(distinguisher, (idv = new Idv()));
      }

      idv.importLines(document);
    });

    const result: Record<string, T> = {};

    for (const [distinguisher, idv] of idvMap.entries()) {
      result[distinguisher] = convertIdv(idv);
    }

    return result;
  }
}

// TODO: implement backslash-escaping in the document?
export const StringProperty = (distinguisher: string, lines: string[]) =>
  lines.length == 0 ? distinguisher : lines.join("\n");