/*
 This file is part of GNU Taler
 (C) 2019-2022 Taler Systems S.A.

 GNU Taler is free software; you can redistribute it and/or modify it under the
 terms of the GNU General Public License as published by the Free Software
 Foundation; either version 3, or (at your option) any later version.

 GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 A PARTICULAR PURPOSE.  See the GNU General Public License for more details.

 You should have received a copy of the GNU General Public License along with
 GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

/**
 * Imports.
 */
import * as ts from "typescript";
import * as fs from "node:fs";
import * as path from "node:path";

const DEFAULT_PO_HEADER = `# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\\n"
"Report-Msgid-Bugs-To: \\n"
"POT-Creation-Date: 2016-11-23 00:00+0100\\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n"
"Language-Team: LANGUAGE <LL@li.org>\\n"
"Language: \\n"
"MIME-Version: 1.0\\n"
"Content-Type: text/plain; charset=UTF-8\\n"
"Content-Transfer-Encoding: 8bit\\n"\n\n`;

function wordwrap(str: string, width: number = 80): string[] {
  var regex = ".{1," + width + "}(\\s|$)|\\S+(\\s|$)";
  return str.match(RegExp(regex, "g"));
}

function getTemplate(node: ts.Node): string {
  switch (node.kind) {
    case ts.SyntaxKind.FirstTemplateToken:
      return (<any>node).text;
    case ts.SyntaxKind.TemplateExpression:
      let te = <ts.TemplateExpression>node;
      let textFragments = [te.head.text];
      for (let tsp of te.templateSpans) {
        textFragments.push(`%${(textFragments.length - 1) / 2 + 1}$s`);
        textFragments.push(tsp.literal.text.replace(/%/g, "%%"));
      }
      return textFragments.join("");
    default:
      return "(pogen.ts: unable to parse)";
  }
}

function getComment(
  sourceFile: ts.SourceFile,
  preLastTokLine: number,
  lastTokLine: number,
  node: ts.Node,
): string {
  let lc = ts.getLineAndCharacterOfPosition(sourceFile, node.pos);
  let lastComments: ts.CommentRange[];
  for (let l = preLastTokLine; l < lastTokLine; l++) {
    let pos = ts.getPositionOfLineAndCharacter(sourceFile, l, 0);
    let comments = ts.getTrailingCommentRanges(sourceFile.text, pos);
    if (comments) {
      lastComments = comments;
    }
  }
  if (!lastComments) {
    return;
  }
  let candidate = lastComments[lastComments.length - 1];
  let candidateEndLine = ts.getLineAndCharacterOfPosition(
    sourceFile,
    candidate.end,
  ).line;
  if (candidateEndLine != lc.line - 1) {
    return;
  }
  let text = sourceFile.text.slice(candidate.pos, candidate.end);
  switch (candidate.kind) {
    case ts.SyntaxKind.SingleLineCommentTrivia:
      // Remove comment leader
      text = text.replace(/^[/][/]\s*/, "");
      break;
    case ts.SyntaxKind.MultiLineCommentTrivia:
      // Remove comment leader and trailer,
      // handling white space just like xgettext.
      text = text
        .replace(/^[/][*](\s*?\n|\s*)?/, "")
        .replace(/(\n[ \t]*?)?[*][/]$/, "");
      break;
  }
  return text;
}

function getPath(node: ts.Node): { path: string[]; ctx: string } {
  switch (node.kind) {
    case ts.SyntaxKind.PropertyAccessExpression: {
      let pae = <ts.PropertyAccessExpression>node;
      return {
        path: Array.prototype.concat(getPath(pae.expression).path, [
          pae.name.text,
        ]),
        ctx: "",
      };
    }
    case ts.SyntaxKind.Identifier: {
      let id = <ts.Identifier>node;
      return {
        path: [id.text],
        ctx: "",
      };
    }
    case ts.SyntaxKind.CallExpression: {
      const call = <ts.CallExpression>node;

      const firstArg = call.arguments[0];
      if (
        call.arguments.length === 1 &&
        firstArg.kind === ts.SyntaxKind.StringLiteral
      ) {
        const str = <ts.StringLiteral>firstArg;
        return {
          path: getPath(call.expression).path,
          ctx: str.text,
        };
      }
    }
    default: {
      // console.log("ASDASD", ts.SyntaxKind[node.kind], node);
    }
  }
  return {
    path: ["(other)"],
    ctx: "",
  };
}

function arrayEq<T>(a1: T[], a2: T[]) {
  if (a1.length != a2.length) {
    return false;
  }
  for (let i = 0; i < a1.length; i++) {
    if (a1[i] != a2[i]) {
      return false;
    }
  }
  return true;
}

interface TemplateResult {
  comment: string;
  path: string[];
  template: string;
  line: number;
  context: string;
}

function processTaggedTemplateExpression(
  sourceFile: ts.SourceFile,
  preLastTokLine: number,
  lastTokLine: number,
  tte: ts.TaggedTemplateExpression,
): TemplateResult {
  let path = getPath(tte.tag);
  let res: TemplateResult = {
    path: path.path,
    line: lastTokLine,
    comment: getComment(sourceFile, preLastTokLine, lastTokLine, tte),
    template: getTemplate(tte.template),
    context: path.ctx,
  };
  return res;
}

type ScreenInfo = {
  fileMap: Record<string, Set<string>>;
  missing: Set<String>;
  maxId: number;
};
const SCREEN_INFO: ScreenInfo = {
  fileMap: {},
  missing: new Set(),
  maxId: 0,
};

function formatScreenId(
  sourceFile: ts.SourceFile,
  outChunks: string[],
  screenId: string,
) {
  const screen = Number.parseInt(screenId, 10);
  if (!screen || Number.isNaN(screen)) {
    SCREEN_INFO.missing.add(sourceFile.fileName);
  } else {
    if (!SCREEN_INFO.fileMap[screenId]) {
      SCREEN_INFO.fileMap[screenId] = new Set();
    }
    SCREEN_INFO.fileMap[screenId].add(sourceFile.fileName);

    if (SCREEN_INFO.maxId < screen) {
      SCREEN_INFO.maxId = screen;
    }

    outChunks.push(`#. screenid: ${screenId}\n`);
  }
}

function formatMsgComment(
  projectPrefix: string,
  sourceFile: ts.SourceFile,
  outChunks: string[],
  line: number,
  comment?: string,
) {
  if (comment) {
    for (let cl of comment.split("\n")) {
      outChunks.push(`#. ${cl}\n`);
    }
  }
  const fn = path.relative(projectPrefix, sourceFile.fileName);
  outChunks.push(`#: ${fn}:${line + 1}\n`);
  outChunks.push(`#, c-format\n`);
}

function formatMsgLine(outChunks: string[], head: string, msg: string) {
  const m = msg.match(/(.*\n|.+$)/g);
  if (!m) return;
  // Do escaping, wrap break at newlines
  // console.log("head", JSON.stringify(head));
  // console.log("msg", JSON.stringify(msg));
  let parts = m
    .map((x) => x.replace(/\n/g, "\\n").replace(/"/g, '\\"'))
    .map((p) => wordwrap(p))
    .reduce((a, b) => a.concat(b));
  if (parts.length == 1) {
    outChunks.push(`${head} "${parts[0]}"\n`);
  } else {
    outChunks.push(`${head} ""\n`);
    for (let p of parts) {
      outChunks.push(`"${p}"\n`);
    }
  }
}

function getJsxElementPath(node: ts.Node) {
  let path;
  let process = (childNode) => {
    switch (childNode.kind) {
      case ts.SyntaxKind.JsxOpeningElement: {
        let e = childNode as ts.JsxOpeningElement;
        return (path = getPath(e.tagName).path);
      }
      default:
        break;
    }
  };
  ts.forEachChild(node, process);
  return path;
}

function translateJsxExpression(node: ts.Node, h) {
  switch (node.kind) {
    case ts.SyntaxKind.StringLiteral: {
      let e = node as ts.StringLiteral;
      return e.text;
    }
    default:
      return `%${h[0]++}$s`;
  }
}

function trim(s: string) {
  return s.replace(/^[ \n\t]*/, "").replace(/[ \n\t]*$/, "");
}

function getJsxAttribute(sour: ts.SourceFile, node: ts.Node) {
  const result = {};
  ts.forEachChild(node, (childNode: ts.Node) => {
    switch (childNode.kind) {
      case ts.SyntaxKind.JsxOpeningElement: {
        let e = childNode as ts.JsxOpeningElement;

        e.attributes.properties.map((p) => {
          const id = p.getChildAt(0, sour).getText(sour);
          const v = p.getChildAt(2, sour);
          if (v.kind !== ts.SyntaxKind.StringLiteral) {
            return undefined;
          }
          result[id] = JSON.parse(v.getText(sour));
        });
        return;
      }
    }
  });
  return result;
}

function getJsxContent(node: ts.Node) {
  let fragments = [];
  let holeNum = [1];
  let process = (childNode) => {
    switch (childNode.kind) {
      case ts.SyntaxKind.JsxText: {
        let e = childNode as ts.JsxText;
        let s = e.text;
        let t = s.split("\n").map(trim).join(" ");
        if (s[0] === " ") {
          t = " " + t;
        }
        if (s[s.length - 1] === " ") {
          t = t + " ";
        }
        fragments.push(t);
      }
      case ts.SyntaxKind.JsxOpeningElement:
        break;
      case ts.SyntaxKind.JsxSelfClosingElement:
      case ts.SyntaxKind.JsxElement:
        fragments.push(`%${holeNum[0]++}$s`);
        break;
      case ts.SyntaxKind.JsxExpression: {
        let e = childNode as ts.JsxExpression;
        fragments.push(translateJsxExpression(e.expression, holeNum));
        break;
      }
      case ts.SyntaxKind.JsxClosingElement:
        break;
      default:
        console.log("unhandled node type: ", childNode.kind);
        let lc = ts.getLineAndCharacterOfPosition(
          childNode.getSourceFile(),
          childNode.getStart(),
        );
        console.error(
          `unrecognized syntax in JSX Element ${
            ts.SyntaxKind[childNode.kind]
          } (${childNode.getSourceFile().fileName}:${lc.line + 1}:${
            lc.character + 1
          }`,
        );
        break;
    }
  };
  ts.forEachChild(node, process);
  return fragments.join("").trim().replace(/ +/g, " ");
}

function getJsxSingular(node: ts.Node) {
  let res;
  let process = (childNode) => {
    switch (childNode.kind) {
      case ts.SyntaxKind.JsxElement: {
        let path = getJsxElementPath(childNode);
        if (arrayEq(path, ["i18n", "TranslateSingular"])) {
          res = getJsxContent(childNode);
        }
      }
      default:
        break;
    }
  };
  ts.forEachChild(node, process);
  return res;
}

function getJsxPlural(node: ts.Node) {
  let res;
  let process = (childNode) => {
    switch (childNode.kind) {
      case ts.SyntaxKind.JsxElement: {
        let path = getJsxElementPath(childNode);
        if (arrayEq(path, ["i18n", "TranslatePlural"])) {
          res = getJsxContent(childNode);
        }
      }
      default:
        break;
    }
  };
  ts.forEachChild(node, process);
  return res;
}

function searchScreenId(parents: ts.Node[], sourceFile: ts.SourceFile) {
  var result = undefined;
  parents.forEach((parent) => {
    // console.log("parent => ", ts.SyntaxKind[parent.kind]);
    if (result) return;
    parent.forEachChild((node) => {
      // console.log("     children => ", ts.SyntaxKind[node.kind]);

      switch (node.kind) {
        case ts.SyntaxKind.VariableStatement: {
          const v = node as ts.VariableStatement;
          const found = v.declarationList.declarations.find(
            (d) => d.name.getText(sourceFile) === "TALER_SCREEN_ID",
          );
          if (found) {
            if (found.initializer.kind === ts.SyntaxKind.NumericLiteral) {
              const id = found.initializer.getText(sourceFile);
              result = id;
            } else {
              console.error("TALER_SCREEN_ID but is not a NumericLiteral");
            }
            return;
          }
        }
        // case ts.SyntaxKind.VariableDeclaration: {
        //   const v = node as ts.VariableDeclaration;
        //   console.log(v);
        //   return;
        // }
      }
    });
  });
  // console.log("");
  return result;
}

function processNode(
  parents: ts.Node[],
  node: ts.Node,
  preLastTokLine: number,
  lastTokLine: number,
  sourceFile: ts.SourceFile,
  outChunks: string[],
  knownMessageIds: Set<string>,
  projectPrefix: string,
) {
  switch (node.kind) {
    case ts.SyntaxKind.JsxElement:
      let path = getJsxElementPath(node);
      // <i18n.Translate>text</i18n.Translate>
      if (arrayEq(path, ["i18n", "Translate"])) {
        const content = getJsxContent(node);
        const { line } = ts.getLineAndCharacterOfPosition(sourceFile, node.pos);
        if (!content) {
          throw Error(
            `string to be translated can't be empty: ${sourceFile.fileName}:${line}`,
          );
        }
        const comment = getComment(
          sourceFile,
          preLastTokLine,
          lastTokLine,
          node,
        );
        const context = getJsxAttribute(sourceFile, node)["context"] ?? "";
        const msgid = context + content;
        if (!knownMessageIds.has(msgid)) {
          knownMessageIds.add(msgid);
          const screenId = searchScreenId(parents, sourceFile);
          formatScreenId(sourceFile, outChunks, screenId);
          formatMsgComment(projectPrefix, sourceFile, outChunks, line, comment);
          formatMsgLine(outChunks, "msgctxt", context);
          formatMsgLine(outChunks, "msgid", content);
          outChunks.push(`msgstr ""\n`);
          outChunks.push("\n");
        }
        return;
      }
      if (arrayEq(path, ["i18n", "TranslateSwitch"])) {
        const { line } = ts.getLineAndCharacterOfPosition(sourceFile, node.pos);
        const comment = getComment(
          sourceFile,
          preLastTokLine,
          lastTokLine,
          node,
        );
        formatMsgComment(projectPrefix, sourceFile, outChunks, line, comment);
        const content = getJsxSingular(node);
        if (!content) {
          throw Error(
            `string to be translated can't be empty, singular is missing: ${sourceFile.fileName}:${line}`,
          );
        }
        const pluralForm = getJsxPlural(node);
        if (!pluralForm) {
          throw Error(
            `string to be translated can't be empty, plural is missing: ${sourceFile.fileName}:${line}`,
          );
        }
        const context = getJsxAttribute(sourceFile, node)["context"] ?? "";
        const msgid = context + content;
        if (!knownMessageIds.has(msgid)) {
          knownMessageIds.add(msgid);
          const screenId = searchScreenId(parents, sourceFile);
          formatScreenId(sourceFile, outChunks, screenId);
          formatMsgLine(outChunks, "msgctxt", context);
          formatMsgLine(outChunks, "msgid", content);
          formatMsgLine(outChunks, "msgid_plural", pluralForm);
          outChunks.push(`msgstr[0] ""\n`);
          outChunks.push(`msgstr[1] ""\n`);
          outChunks.push(`\n`);
        }
        return;
      }
      break;
    case ts.SyntaxKind.CallExpression: {
      // might be i18n.plural(i18n[.X]`...`, i18n[.X]`...`)
      let ce = <ts.CallExpression>node;
      let path = getPath(ce.expression);
      if (!arrayEq(path.path, ["i18n", "plural"])) {
        break;
      }
      if (ce.arguments[0].kind != ts.SyntaxKind.TaggedTemplateExpression) {
        break;
      }
      if (ce.arguments[1].kind != ts.SyntaxKind.TaggedTemplateExpression) {
        break;
      }
      let { line } = ts.getLineAndCharacterOfPosition(sourceFile, ce.pos);
      const tte1 = <ts.TaggedTemplateExpression>ce.arguments[0];
      let lc1 = ts.getLineAndCharacterOfPosition(sourceFile, tte1.pos);
      if (lc1.line != lastTokLine) {
        preLastTokLine = lastTokLine; // HERE
        lastTokLine = lc1.line;
      }
      const t1 = processTaggedTemplateExpression(
        sourceFile,
        preLastTokLine,
        lastTokLine,
        tte1,
      );
      const content = t1.template;
      if (!content) {
        throw Error(
          `string to be translated can't be empty: ${sourceFile.fileName}:${line}`,
        );
      }

      const tte2 = <ts.TaggedTemplateExpression>ce.arguments[1];
      let lc2 = ts.getLineAndCharacterOfPosition(sourceFile, tte2.pos);
      if (lc2.line != lastTokLine) {
        preLastTokLine = lastTokLine; // HERE
        lastTokLine = lc2.line;
      }
      let t2 = processTaggedTemplateExpression(
        sourceFile,
        preLastTokLine,
        lastTokLine,
        tte2,
      );
      let comment = getComment(sourceFile, preLastTokLine, lastTokLine, ce);
      const msgid = path.ctx + content;
      if (!knownMessageIds.has(msgid)) {
        knownMessageIds.add(msgid);
        const screenId = searchScreenId(parents, sourceFile);
        formatScreenId(sourceFile, outChunks, screenId);
        formatMsgComment(projectPrefix, sourceFile, outChunks, line, comment);
        formatMsgLine(outChunks, "msgctxt", path.ctx);
        formatMsgLine(outChunks, "msgid", content);
        formatMsgLine(outChunks, "msgid_plural", t2.template);
        outChunks.push(`msgstr[0] ""\n`);
        outChunks.push(`msgstr[1] ""\n`);
        outChunks.push("\n");
      }

      // Important: no processing for child i18n expressions here
      return;
    }
    case ts.SyntaxKind.TaggedTemplateExpression: {
      let tte = <ts.TaggedTemplateExpression>node;
      let lc2 = ts.getLineAndCharacterOfPosition(sourceFile, tte.pos);
      if (lc2.line != lastTokLine) {
        preLastTokLine = lastTokLine;
        lastTokLine = lc2.line;
      }
      const { comment, template, line, path, context } =
        processTaggedTemplateExpression(
          sourceFile,
          preLastTokLine,
          lastTokLine,
          tte,
        );
      if (path[0] != "i18n") {
        break;
      }
      if (!template) {
        throw Error(
          `string to be translated can't be empty: ${sourceFile.fileName}:${line}`,
        );
      }
      const msgid = context + template;
      if (!knownMessageIds.has(msgid)) {
        knownMessageIds.add(msgid);
        const screenId = searchScreenId(parents, sourceFile);
        formatScreenId(sourceFile, outChunks, screenId);
        formatMsgComment(projectPrefix, sourceFile, outChunks, line, comment);
        formatMsgLine(outChunks, "msgctxt", context);
        formatMsgLine(outChunks, "msgid", template);
        outChunks.push(`msgstr ""\n`);
        outChunks.push("\n");
      }
      break;
    }
  }

  ts.forEachChild(node, (child) => {
    processNode(
      [node, ...parents],
      child,
      lastTokLine,
      preLastTokLine,
      sourceFile,
      outChunks,
      knownMessageIds,
      projectPrefix,
    );
  });
}

export function processFileForTesting(sourceFile: ts.SourceFile): string {
  const result: string[] = new Array<string>();
  processNode([], sourceFile, 0, 0, sourceFile, result, new Set<string>(), "");
  return result.join("");
}

export function processFile(
  sourceFile: ts.SourceFile,
  outChunks: string[],
  knownMessageIds: Set<string>,
  projectPrefix: string,
) {
  // let lastTokLine = 0;
  // let preLastTokLine = 0;
  processNode(
    [],
    sourceFile,
    0,
    0,
    sourceFile,
    outChunks,
    knownMessageIds,
    projectPrefix,
  );
}

function searchIntoParents(directory: string, fileFlag: string) {
  if (!path.isAbsolute(directory)) {
    return searchIntoParents(path.join(process.cwd(), directory), fileFlag);
  }
  const parent = path.dirname(directory);
  if (fs.existsSync(path.join(directory, fileFlag))) {
    return directory;
  }
  if (parent === directory) {
    return directory;
  }
  return searchIntoParents(parent, fileFlag);
}

export function potextract() {
  const configPath = ts.findConfigFile(
    /*searchPath*/ "./",
    ts.sys.fileExists,
    "tsconfig.json",
  );
  if (!configPath) {
    throw new Error("Could not find a valid 'tsconfig.json'.");
  }

  const cmdline = ts.getParsedCommandLineOfConfigFile(
    configPath,
    {},
    {
      fileExists: ts.sys.fileExists,
      getCurrentDirectory: ts.sys.getCurrentDirectory,
      onUnRecoverableConfigFileDiagnostic: (e) => console.log(e),
      readDirectory: ts.sys.readDirectory,
      readFile: ts.sys.readFile,
      useCaseSensitiveFileNames: true,
    },
  );

  const prog = ts.createProgram({
    options: cmdline.options,
    rootNames: cmdline.fileNames,
  });

  const allFiles = prog.getSourceFiles();

  const ownFiles = allFiles.filter(
    (x) =>
      !x.isDeclarationFile &&
      !prog.isSourceFileFromExternalLibrary(x) &&
      !prog.isSourceFileDefaultLibrary(x),
  );

  let header: string;
  try {
    header = fs.readFileSync("src/i18n/poheader", "utf-8");
  } catch (e) {
    header = DEFAULT_PO_HEADER;
  }

  const gitRoot = searchIntoParents(process.cwd(), ".git");

  const chunks = [header];
  const knownMessageIds = new Set<string>();

  for (const f of ownFiles) {
    processFile(f, chunks, knownMessageIds, gitRoot);
  }

  if (SCREEN_INFO.missing.size) {
    console.error(
      `There are some files with translation strings that doesnt have a constant TALER_SCREEN_ID on the root. This constant should be a unquie integer number to facilitate the identification from other locations. Add a "const TALER_SCREEN_ID = <number>;" after the last import. It doesn't need to be exported and it can be removed from runtime. Files:`,
    );
    SCREEN_INFO.missing.forEach((fileName) => {
      console.error(` * ${fileName}`);
    });
  }
  const haveRepetition =
    Object.values(SCREEN_INFO.fileMap).find((files) => files.size > 1) !==
    undefined;
  if (haveRepetition) {
    console.error(
      `There are more than one file with the same TALER_SCREEN_ID, the current max number is "${SCREEN_INFO.maxId}". Repeated ids:`,
    );
    Object.entries(SCREEN_INFO.fileMap).forEach(([screenId, files]) => {
      if (files.size > 1) {
        console.error(
          `const TALER_SCREEN_ID = ${screenId};\n * ${[...files].join(
            "\n * ",
          )}`,
        );
      }
    });
  }

  const pot = chunks.join("");

  //console.log(pot);

  const packageJson = JSON.parse(
    fs.readFileSync("./package.json", { encoding: "utf-8" }),
  );

  const poDomain = packageJson.pogen?.domain;
  if (!poDomain) {
    console.error("missing 'pogen.domain' field in package.json");
    process.exit(1);
  }
  fs.writeFileSync(`./src/i18n/${poDomain}.pot`, pot);
}
