// you may not need all of these but they come up a lot
import fs from "fs";
import assert from "assert";
import logging from '../lib/logging.js';
import { mkdir_to, glob } from "../lib/builderator.js";
import { create_renderer } from "../lib/docgen.js";
import path from "path";
import slugify from "slugify";
import * as acorn from "acorn";
import * as acorn_walk from "acorn-walk";

const log = logging.create(import.meta.url);

export const description = "Describe your command here."

// your command uses the npm package commander's options format
export const options = [
  ["--quiet", "Don't output the stats at the end."],
  ["--readme", "README file to use as the initial page", "README.md"]
]

// example of a positional argument, it's the 1st argument to main
export const argument = ["<source...>", "source input globs"];

// put required options in the required variable
export const required = [
  ["--output <string>", "Save to file rather than stdout."],
]

const RENDERER = create_renderer();
const CAPS_WORDS = ["BUG", "TODO", "WARNING", "FOOTGUN", "DEPRECATED"];
const STATS = {total: 0, docs: 0, undoc: 0};

const slug = (instring) => slugify(instring, { lower: true, strict: true, trim: true});

/*
  Strips 1 leading space from the comments, or the \s\* combinations
  in traditional documentation comments.

  If we strip more it'll mess up formatting in markdown for indentation
  formats. Weirdly Remarkable seems to be able to handle leading spaces pretty
  well so only need to remove one space or \s\* combinations like with
  traditional comment docs.
*/
const render_comment = (comment) => {
  const lines = comment.split(/\n/).map(l => l.replace(/^(\s*\*\s?|\s)/, ''));
  return RENDERER.render(lines.join("\n"));
}

/* Handy function for checking things are good and aborting. */
const check = (test, fail_message) => {
  if(!test) {
    log.error(fail_message);
    process.exit(1);
  }
}

const dump = (obj) => {
  return JSON.stringify(obj, null, 4);
}

class ParseWalker {
  constructor(comments, code) {
    this.comments = comments;
    this.exported = [];
    this.code = code;
  }

  handle_class(root) {
    const new_class = {
      isa: "class",
      slug: slug(root.declaration.id.name),
      name: root.declaration.id.name,
      line_start: root.loc.start.line,
      methods: [],
    }

    acorn_walk.simple(root, {
      ClassDeclaration: (cls_node) => {
        assert(cls_node.id.name === new_class.name, "Name of class changed!");
        new_class.range = [cls_node.start, cls_node.body.start];
        this.add_export(cls_node.id, new_class);
      },

      MethodDefinition: (meth_node) => {
        const new_method = {
          isa: "method",
          static: meth_node.static,
          async: meth_node.value.async,
          generator: meth_node.value.generator,
          slug: slug(`${new_class.name}-${meth_node.key.name}`),
          name: meth_node.key.name,
          line_start: meth_node.loc.start.line,
          range: [meth_node.start, meth_node.value.body.start],
          params: this.handle_params(meth_node.value.params),
          comment: this.find_comment(meth_node.loc.start.line),
        }

        this.has_CAPS(new_method);

        new_method.code = this.slice_code(new_method.range);

        this.update_stats(new_method); // methods can't go through add_export
        new_class.methods.push(new_method);
      }
    });
  }

  handle_params(param_list) {
    const result = [];

    for(let param of param_list) {
      acorn_walk.simple(param, {
        Identifier: (_node) => {
          result.push({isa: "identifier", name: _node.name});
        },

        AssignmentPattern: (_node) => {
          result.push({
            isa: "assignment",
            name: _node.left.name,
            right: {
              type: _node.right.type.toLowerCase(),
              raw: _node.right.raw,
              value: _node.right.value,
              name: _node.right.name,
            },
          });
        }
      });
    }

    return result;
  }

  /*
    Used to add information when something is mentioned in the
    comment like BUG, TODO, etc.
   */
  has_CAPS(exp) {
    if(exp.comment) {
      exp.caps = CAPS_WORDS.filter(phrase => exp.comment.includes(phrase));
    } else {
      exp.caps = [];
    }
  }

  update_stats(exp) {
    STATS.total += 1;

    if(exp.comment) {
      STATS.docs += 1;
    } else {
      STATS.undoc += 1;
    }
  }

  add_export(id, exp) {
    exp.name = id.name;
    exp.slug = exp.slug ? exp.slug : slug(id.name);
    exp.line_start = id.loc.start.line;
    exp.comment = this.find_comment(exp.line_start);
    this.has_CAPS(exp);
    exp.code = this.slice_code(exp.range);
    this.exported.push(exp);

    this.update_stats(exp);
  }

  slice_code(range) {
    return this.code.slice(range[0], range[1]);
  }

  handle_arrow_func(id, arrow) {
    this.add_export(id, {
      isa: "function",
      async: arrow.async,
      generator: arrow.generator,
      expression: arrow.expression,
      range: [id.start, arrow.body.start],
      params: this.handle_params(arrow.params),
    });
  }

  handle_variable(root) {
    const declare = root.declaration.declarations[0];
    const id = declare.id;
    const _node = declare.init;
    const init_is = declare.init.type;

    if(init_is === "ArrowFunctionExpression") {
      this.handle_arrow_func(id, declare.init);
    } else {
      this.add_export(id, {
        isa: _node.type.toLowerCase(),
        value: _node.value,
        range: declare.range,
        raw: _node.raw
      });
    }
  }

  /*
   Find the nearest comment to this line, giving
   about 2 lines of slack.
   */
  find_comment(line) {
    for(let c of this.comments) {
      const distance = c.end - line;
      if(!c.found && distance == -1) {
        c.found = true;
        return render_comment(c.value);
      }
    }

    return undefined;
  }


  /*
   Returns the first comment as the file's main doc comment, or undefined if there isn't one.
   */
  file_comment() {
    const comment = this.comments[0];

    if(comment && comment.start === 1) {
      // kind of a hack, but find_comment will find this now
      return this.find_comment(comment.end + 1);
    } else {
      return undefined;
    }
  }

  handle_export(_node) {
    switch(_node.declaration.type) {
      case "ClassDeclaration":
        this.handle_class(_node);
        break;
      case "VariableDeclaration": {
        this.handle_variable(_node);
        break;
      }
      default:
        console.log(">>>", _node.declaration.type);
    }
  }
}

const parse_source = (source) => {
  const code = fs.readFileSync(source);

  let comments = [];

  const acorn_opts = {
    sourceType: "module",
    ecmaVersion: "2023",
    locations: true,
    sourceFile: source,
    ranges: true,
    onComment: comments
  }

  const parsed = acorn.parse(code, acorn_opts);

  comments = comments.filter(c => c.type === "Block").map(c => {
    return {
      start: c.loc.start.line,
      end: c.loc.end.line,
      value: c.value,
      type: "comment",
      found: false,
    }
  });

  const walker = new ParseWalker(comments, code.toString());
  // acorn is stupid and they grab a reference to the functions so that _removes_
  // this from the object, instead of just...calling walker.function() like a normal person
  acorn_walk.simple(parsed, {
    ExportNamedDeclaration: (_node) => walker.handle_export(_node),
  });

  let comment = walker.file_comment();

  return {
    // normalize to / even on windows
    source: source.replaceAll("\\", "/"),
    // find the first comment for the file's comment
    comment,
    exports: walker.exported,
    orphan_comments: walker.comments.filter(c => !c.found)
  };
}

const normalize_name = (fname) => {
  const no_slash = fname.replaceAll("\\", "/");

  if(fname.startsWith("./")) {
    return no_slash.slice(2);
  } else if(fname.startsWith("/")) {
    return no_slash.slice(1);
  } else {
    return no_slash;
  }
}


export const main = async (source_globs, opts) => {
  const index = {};
  mkdir_to(opts.output);

  for(let source of source_globs) {
    const source_list = glob(source);

    for(let fname of source_list) {
      const result = parse_source(fname);

      const target = `${path.join(opts.output, fname)}.json`;
      mkdir_to(target);
      fs.writeFileSync(target, dump(result));

      const name = normalize_name(fname);

      index[name] = result.exports.map(e => {
        return {isa: e.isa, name: e.name};
      });
    }
  }

  // now write the grand index
  const index_name = path.join(opts.output, "index.json");
  fs.writeFileSync(index_name, dump(index));

  // render the README.md to the initial docs
  const readme_name = path.join(opts.output, "index.html");
  const md_out = RENDERER.render(fs.readFileSync(opts.readme).toString());
  fs.writeFileSync(readme_name, md_out);

  const percent = Math.floor(100 * STATS.docs / STATS.total);

  if(!opts.quiet) {
    console.log(`Total ${STATS.total}, ${percent}% documented (${STATS.docs} docs vs. ${STATS.undoc} no docs).`);
  }

  process.exit(0);
}
