import fs from "node:fs";
import path from "node:path";

export default class Template {
  private views: string;
  private globals: Record<string, any>;
  private cache: boolean;
  private templates: Map<string, { code: string; cached: Date }>;
  private debug: boolean;

  constructor() {
    this.views = "./views";
    this.globals = {};
    this.debug = false;
    this.cache = true;
    this.templates = new Map();
  }

  /**
   * Enables or disables debug mode.
   * @param debug - If true, enables debug mode.
   */
  setDebug(debug: boolean): void {
    this.debug = debug;
  }

  /**
   * Sets the directory for template files and preloads all templates.
   * @param views - The directory path for template files.
   */
  setViews(views: string): void {
    this.views = views;
    this.readdir(views);
  }

  /**
   * Sets global variables to be used in all templates.
   * @param globals - An object containing global variables.
   */
  setGlobals(globals: Record<string, any>): void {
    this.globals = globals;
  }

  /**
   * Enables or disables the template caching mechanism.
   * @param cache - If true, enables caching.
   */
  setCache(cache: boolean): void {
    this.cache = cache;
  }

  /**
   * Recursively reads the specified directory and loads all templates into memory.
   * @param dir - The directory to read.
   * @param root - The root directory for relative paths.
   */
  private readdir(dir: string, root: string = dir): void {
    for(const d of fs.readdirSync(`${path.resolve()}/${dir}`)) {
      if(d.endsWith(".html")) {
        const file = path.parse(`${dir.replace(this.views, "")}/${d}`);
        const t_dir = file.dir.substring(1);
        const t_file = file.name;
        this.getTemplate(!t_dir ? t_file : `${t_dir}/${t_file}`);
      }
      else {
        this.readdir(`${dir}/${d}`, root);
      }
    }
  }

  /**
   * Retrieves a template from the cache or loads it from the file system if not cached.
   * @param tpl - The name of the template.
   * @returns The template code as a string.
   */
  private getTemplate(tpl: string): string {
    let template: { code: string; cached: Date };
    let cache = false;
    if(this.cache && this.templates.has(tpl)) {
      template = this.templates.get(tpl)!;
      cache = true;
    }
    else {
      template = {
        code: fs.readFileSync(`${this.views}/${tpl}.html`, "utf-8"),
        cached: new Date(),
      };
      this.templates.set(tpl, template);
    }
    return [
      template.code,
      this.debug ? `<!-- ${tpl}.html ${cache ? `cached ${template.cached}` : "not cached"} -->` : "",
    ].join("");
  }

  /**
   * Renders a template with the provided data and local variables.
   * @param file - The name of the template file (without extension).
   * @param data - Data object to inject into the template.
   * @param locals - Local variables to be used within the template.
   * @returns The rendered HTML string.
   */
  render(file: string, data: Record<string, any> = {}, locals: Record<string, any> = {}): string {
    data = { ...data, ...locals, ...this.globals };
    try {
      const code =
        'with(_data){const __html = [];' +
        '__html.push(`' +
        this.getTemplate(file)
          .replace(/[\t]/g, " ")
          .split("`")
          .join("\\`")
          .replace(/{{--(.*?)--}}/g, "")
          .replace(/{{(.+?)}}/g, "`, $1, `")
          .replace(/{!!(.+?)!!}/g, "`, this.escape($1), `")
          .replace(/@js/g, "`);")
          .replace(/@endjs/g, ";__html.push(`")
          .replace(/@mtime\((.*?)\)/g, "`);__html.push(this.getMtime('$1'));__html.push(`")
          .replace(/@include\((.*?)\)/g, (_, inc) => this.render(inc, data))
          .replace(/@for\((.*?)\)$/gm, "`); for($1) { __html.push(`")
          .replace(/@endfor/g, "`); } __html.push(`")
          .replace(/@each\((.*?) as (.*?)\)/g, "`); this.forEach($1, ($2, key) => { __html.push(`")
          .replace(/@endeach/g, "`); }); __html.push(`")
          .replace(/@elseif\((.*?)\)(\)?)/g, "`); } else if ($1$2) { __html.push(`")
          .replace(/@if\((.*?)\)(\)?)/g, "`); if ($1$2) { __html.push(`")
          .replace(/@else/g, "`); } else { __html.push(`")
          .replace(/@endif/g, "`); } __html.push(`") +
        "`); return __html.join('').replace(/\\n\\s*\\n/g, '\\n'); }";

      return new Function("_data", code).bind({
        escape: this.escape,
        forEach: this.forEach,
        getMtime: this.getMtime,
      })(data);
    }
    catch(err: any) {
      console.log(file, (err as Error).message);
      return this.debug ? `${(err as Error).message} in ${file}` : "";
    }
  }

  /**
   * Escapes a string for safe usage in HTML.
   * @param str - The string to escape.
   * @returns The escaped string.
   */
  escape(str: string): string {
    return (str + "")
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;")
      .replace(/'/g, "&#39;")
      .replace(/{/g, "&#123;")
      .replace(/}/g, "&#125;");
  }

  /**
   * Iterates over an object or array and applies a callback function.
   * @param o - The object or array to iterate over.
   * @param f - The callback function.
   */
  forEach(o: any, f: (value: any, key: string | number) => void): void {
    if(Array.isArray(o)) {
      o.forEach(f);
    }
    else if(typeof o === "object") {
      Object.keys(o).forEach((k) => f.call(null, o[k], k));
    }
    else {
      throw new Error(`${o} is not an iterable object`);
    }
  }

  /**
   * Retrieves the last modification time of a file.
   * @param file - The file path to check.
   * @returns The last modification time in milliseconds.
   */
  getMtime(file: string): number {
    try {
      return +`${fs.statSync(path.normalize(process.cwd() + file)).mtimeMs}`.split(".")[0];
    }
    catch(err: any) {
      console.log(err);
      return 0;
    }
  }
}