/*
 The ORMish object-relational-mapping-ish library for knex. It's just enough
 ORM added to knex to be useful, and anything else you need can be done with
 knex.  If there's any situation where you can't do something in ORMish it's
 expected that you will access the `knex` variable in thie module and simply use
 knex directly.  Here's how you'd do that:

 ```javascript
 import { knex } from './lib/ormish.js';

 // do your own update using a model's table name
 knex(somemodel.table_name).where(where).update(what);
 ```

 This makes it easy to use ORMish, but still break out when you need to create
 custom queries or speed things up.  The only advice here is to put any special
 operations in a `class Model` subclass as a member function.
 */
import config from '../knexfile.cjs';
import knexConfig from 'knex';
import assert from 'assert';
import { attachPaginate } from 'knex-paginate';

/*
 A preconfigured knex.js driver using the config.development configuration
 by default.

 ___TODO___: Need to make this configurable, even though I just use one config right now since I run sqlite3 all the time.
 */
export const knex = knexConfig(config.development);

/*
 run the PERF_TRICKS to configure sqlite3 when thing start, really need to make this
 a configuration and only do it with sqlite3, but for now just get this done
 */
if(config.development.client === "sqlite3") {
  const PERF_TRICKS = [
    "pragma journal_mode = WAL", // use a WAL journal to not block writers/readers
    "pragma synchronous = normal", // set concurrency to normal now that we have WAL
    "pragma temp_store = memory", // use RAM to make the temporary indices
    // "pragma mmap_size = 30000000000", // use 30GB of mmap to store DB, not useful in multi-process settings
    // "pragma page_size = 32768", // improve performance only if you store large stuff
     "pragma vacuum",  // give it a vacuum, this could impact startup time significantly
    "pragma optimize", // optimize it, but should be done periodically
  ];

  for(let sql of PERF_TRICKS) {
    await knex.raw(sql);
  }
}

attachPaginate();

/*
 Filled in by `load_schema` to give access to the database scheme in the admin
 tool and generally through the API.
 */
export const SCHEMA = {};

const load_schema = async () => {
  const raw = await knex("sqlite_schema").where({"type": "table"}).select(['type', 'name']);
  for(let table of raw) {
    table._columns = await knex(table.name).columnInfo();
    SCHEMA[table.name] = table;
  }
}

await load_schema();

/*
 In some cases (like the generic admin) you need to get validation
 rules but you don't have a specific class to work with.  This function
 is called by Model.validation and you can call it directly to get rules
 for a database table.


 ___BUG___: validator doesn't do proper date formatting yet, so it won't support date types.

 1. `name string` - the table name.
 2. `rules Object` - default rules with empty "" for the rules you want filled in
 3. `all boolean` - set this to true if you want everything
 4. `no_id boolean` - defaults to true, set false if you also want the id
 5. `return Object` - the resulting rules to use with Validator
 */
export const validation = (name, rules, all=false, no_id=true) => {
  assert(rules, "rules parameter is required and will be modified");
  const schema = SCHEMA[name];
  assert(schema, `There is no schema for table named ${name}. Did you forget to migrate:latest?`);

  for(let [key, opts] of Object.entries(schema._columns)) {
    // most of the time you don't want the id
    if(no_id && key === "id") continue;

    if(all || rules[key] === "") {
      let required = opts.nullable || opts.defaultValue ? "nullable|" : "required|";

      switch(opts.type) {
        case "varchar": // fallthrough
        case "text":
          // some databases have an email type but we'll also look for ones named email
          if(key === "email") {
            rules[key] = required + "email";
          } else {
            rules[key] = required + "string";
          }

          if(opts.maxLength) {
            rules[key] += `|min:0|max:${opts.maxLength}`;
          }
          break;
        case "boolean":
          rules[key] = required + "boolean";
          break;
        case "integer":
        case "float":
          rules[key] = required + "numeric";
          break;
        case "date":
          // rules[key] = required + "date";
          break;
        case "email":
          // not all databases have this
          rules[key] = required + "email";
          break;
        default:
          rules[key] = required;
      }
    }
  }

  return rules;
}

/*
 The base class for all models found in `lib/models.js`.  You use this by extending it with:

 ```javascript
 class User extends Model.from_table('user') {
 }
 ```

 This will create a `User` class that is automatically configured using the SCHEMA create from the `user` table in your database.  You won't need to define the attributes on this class as it will be correctly populated from the database.

 The database is therefore the "source of truth" for all of the models.  You can then add functions to extend what this class does.
 */
export class Model {

  /*
   Allows you to build a new object of this Model with the given `attr`
   already set, but really you should use the `Model.from(attr)` method instead.
   This does _no_ object sanitization with `Model.clean(attr)` method, and if
   it doesn't match the underlying database it will throw an exception.

   - `attr Object` - the attributes for this model
   */
  constructor(attr) {
    assert(attr, "Must give attributes.");
    Object.assign(this, attr);
  }

  /*
   How to actually create a new instance of this model. This
   will do two things:

   1. Correctly use the schema for the subclass model.
   2. Sanitize the input to remove anything that shouldn't be in the database.

   The `also_remove` parameter is a list of additional keys to also scrub from the object.

   - `attr Object` -- The attributes this should start with.
   - `also_remove Array` -- list of additional attributes to remove.
   */
  static from(attr, also_remove=undefined) {
    return new this(this.clean(attr, also_remove));
  }

  /*
   Returns an object representing the schema for this Model. Remember that this
   will reflect what's in the database schema, which is formatted however
   `knex.js` formats your database Schema.  Might not be portable between
   databases and only tested with SQlite3.

   _This is an attribute accessor, so just do `obj.schema` rather than call it like a function._

   - `return Object` - The schema for this model.
   */
  get schema() {
    return this.constructor.schema;
  }

  /*
   Uses the `this.schema` scrub out any attributes that are not valid for the
   schema.  This is effectively a whitelist for the allowed attributes based on
   the database schema.  You can use the `also_remove` parameter to list
   additional attributes to remove, which you should do to sanitize incoming
   objects for things like password fields.

   - `attr Object` - The attributes to clean.
   - `also_remove Array` - Additional attributes to remove.
   */
  static clean(attr, also_remove=undefined) {
    assert(attr, "Must give attributes to clean.");

    let clean_entries = Object.entries(attr)
      .filter(([k,v]) => k in this.schema);

    if(also_remove) also_remove.forEach(k => delete clean_entries[k]);

    return Object.fromEntries(clean_entries);
  }

  /*
   Returns the name of this model's current table. Used mostly internally but
   useful otherwise.  This is an attribute accessor (get) so you can just do:

   ```javascript
   const name = obj.table_name;
   ```

   Instead of calling it like a function.  The main reason to use this is in
   tools like the admin table browser found in `admin/pages/Table.svelte` and
   `api/admin/table.js` so check those files out to see what's going on.
  */
  get table_name() {
    return this.constructor.table_name;
  }

  /*
    Returns an object of basic rules meant for `lib/api.js:validate`
    based on what's in the database.  It's meant to be an easy to
    pass in starter which you can augment.  It expects a set of rules
    with keys you want configured. Any key that's set to an empty string ""
    will be filled in with a minimum rule to match the database schema.

    It's designed to be called once at the top of an api/ handler to get
    a basic set of rules.  You could also run it to print out the rules then
    simply write the rules directly where you need them.  It actually just
    calls the `validation` module function with `validation(this.table_name, rules)`.

    - `param rules {Object}` - rules specifier
   */
  static validation(rules) {
    return validation(this.table_name, rules);
  }

  /*
   Delete this object from the database.  We use the word "destroy" because
   `delete` is reserved. It uses this object's `.id` to determine which object
   to delete and uses this `knex` code:

   ```javascript
   await knex(obj.table_name).where({id: obj.id}).del();
   ```

   As with all of ORMish it doesn't handle any relations or references when it does
   the delete so if you have constraints it will fail.  Use `knex` directly in that
   case.

   + `obj Model` -- the object to destroy, just have id
   */
  static async destroy(obj) {
    assert(this.table_name !== undefined, "You must set class variable table_name.");
    assert(obj.id !== undefined, "No id in object to destroy.");

    await knex(this.table_name).
      where({id: obj.id}).
      del();
  }

  /*
   Does very basic 1-to-1 (1:1) relation, for use inside a custom function that returns
   additional records.  1:1 mappings don't show up too often, as I think most modern database
   designs would rather combine them into one giant table, but it does happen when you need to
   add to a database without changing a "sacred" table.

   This is really just a call to `model.first(where, columns)` and mostly acts as a kind of
   documentation.

   + `model Model` - The other model to query.
   + `where Object` - The knex style selection criteria passed to `Model.first`.
   + `columns` - Passed to `Model.first` to restrict the columns returned.
   + ___return___ `Model` subclass found, or `undefined` if nothing.
   */
  async has_one(model, where, columns) {
    assert(where.id !== undefined, `where must at least have id for has_one ${model.table_name} you have ${JSON.stringify(where)}`);
    return await model.first(where, columns);
  }

  /*
   Maps this model to another with a 1-to-many (1:M) relational mapping. It queries
   the other `model` based on the given { where }, which is usually an `id` in the other
   table.

   For example, if I have a `user` table and a `payment` table, I can have a 1 `user` -> M `payment`
   like this:

   ```javascript
    export class User extends Model.from_table('user') {
      async payments() {
        return await this.has_many(Payment, { user_id: this.id });
      }
    }
    ```

    In this situation my `User` model is querying the `Payment` model for any `payment`
    records that have `user_id=this.id`.  That means if the `user.id` is 1, then it will
    find any `payment` records with `user_id=1`.
   */
  async has_many(model, where, columns) {
    return await model.all(where, columns);
  }

  /*
    Implements a simple many-to-many (M:M) using an intermediary table which
    maps two table's IDs using two columns.  For example, if you have a `User`
    and `Payment` model, and you decide that `Payment` can have multiple users
    then you'd do this:

    ```javascript
    await paid1.many_to_many(User, "payment_user");
    ```

    This is translated into `knex` as:

    ```javascript
    await knex("user").where("id", "in",
      knex("payment_user") // subquery in payment_user
        .select(`user_id as id`)
        .where(`payment_id`, "=", this.id)
    );
    ```

    The inverse operation would be:

    ```javascript
    await user1.many_to_many(Payment, "payment_user");
    ```

    Which is translated into `knex` as:

    ```javascript
    await knex("payment").where("id", "in",
      knex("payment_user") // subquery in payment_user
        .select(`payment_id as id`)
        .where(`user_id`, "=", this.id)
    );
    ```

    ### Performance

    This will fail if you have a massive many-to-many since it uses a subquery to get
    a set of IDs, but in many cases it actually might outperform a more direct complicated
    query.  You should use this, then resort to raw `knex` code to craft a better one as
    needed.

    ### Cleaning

    Everything returned is first ran though `.clean()` to and turned into the
    target model so you can use it directly.  This ensures that if a database
    driver infects the returned data with garbage it will be cleaned and you
    get pure models.  In the above example you'd get a `User` or `Payment`
    with only what's in the schema.

    ### Attributed Relations

    If your relation table has extra attributes--a super useful trick--then this
    will not pick them up.  In our example above, if you have another field
    `payment_user.transaction_date` in addition to `user_id` and `payment_id` then
    you won't get the `transaction_date`.  In that case--you guessed it--use `knex`
    directly to query for those.
   */
  async many_to_many(model, through_table) {
    // SECURITY: doing string interpolation which might allow injecting SQL
    let query = knex(model.table_name).where("id", "in",
      knex(through_table).select(`${model.table_name}_id as id`).where(`${this.table_name}_id`, "=", this.id));

    let rows = await query;

    let results = [];

    for(let r of rows) {
      results.push(new model(await model.clean(r)));
    }

    return results;
  }

  /*
    Counts the number of records matching the `where` specification.
    It uses a direct SQL query using the `knex` operation `count`, but
    `knex` returns a "database specific" result.  This will try to
    extract the count result, but will warn you when it can't do that.

    + `where Object` - knex where specification.
    + `columns Array` - columns to return
    + ___return___ `number`
   */
  static async count(where, columns) {
    // the knex count api returns a DB specific result, so we need
    // to specify what we want, which is count:
    const spec = { count: columns || ['id']};
    let res = await knex(this.table_name).where(where).count(spec);
    if(res.length == 1) {
      // single result, just give the count
      return res[0].count;
    } else {
      console.warn("Your call to count in", this.table_name, "using where=", where, "columns: ", columns, "returned a weird result:", res);
      return res; // weird result let them deal with it
    }
  }

  /*
    Your generic insert for this model's table.  The `attr` is checked against the
    database schema by `knex`, so you have to remove anything that doesn't belong.
    Use the `Model.clean` function to do that easily.  Otherwise it works exactly
    like in knex with:

    ```javascript
    await knex(this.table_name).insert(attr);
    ```

    This function expects a return value that has one result with the `id` of
    the inserted row, which might be database specific. These days if a database
    doesn't support this it's a trash database that shouldn't be used.

    + `attr Object` - The attributes to store.
    + ___return___ `number` - returns the id of the inserted object
  */
  static async insert(attr) {
    assert(this.table_name !== undefined, "You must set class variable table_name.");
    assert(attr, `You must give some attr to insert into ${this.table_name}`);

    let res = await knex(this.table_name).insert(attr);
    assert(res, `Failed to get an id from the insert for ${this.table_name}`);

    attr.id = res[0];

    return new this(attr);
  }

  /*
    Implements an upsert (insert but update on conflict) for Postgres, MySQL, and SQLite3 only.

    ___TODO___: allow specifying returns for databases that support it

    + attr `Object` - The attributes to insert or update.
    + conflict_key `string` - The key that can cause a conflict then update.
    + merge `boolean` - Defaults to true and will change the record. false will ignore and not update on conflict.
    + ___return___ `number` - id or undefined
   */
  static async upsert(attr, conflict_key, merge=true) {
    assert(conflict_key !== undefined, `You forgot to set the conflict_key on upsert to table ${this.table_name}`);
    let result = undefined;

    if(merge) {
      result = await knex(this.table_name).insert(attr).onConflict(conflict_key).merge();
    } else {
      result = await knex(this.table_name).insert(attr).onConflict(conflict_key).ignore();
    }

    // returns the id of the row or undefined
    return result !== undefined ? result[0] : undefined;
  }

  /*
   Performs an UPDATE operation on the object `what` found by `where`.  This
   translates into the following `knex.js` code:

   ```javascript
   return knex(this.table_name).where(where).update(what);
   ```

   So it's mostly just syntactic sugar over `knex.js` (like everything in ORMish). If you
   need a more complex `update` then just use `knex()` directly.

   + `where Object` -- The knex where options for a query.
   + `what Model` -- The table/Model to use as the update.
   + ___return___ `number` -- _WARNING_: I believe this returns the count updated but that might be database specific.
   */
  static async update(where, what) {
    assert(where, "You must give a where options.");
    return knex(this.table_name).where(where).update(what);
  }

  /*
    Returns one record from the table based on the `knex` where specification,
    and can limit the columns (attributes) that are returned. This is a `static`
    method so you use it like this:

    ```javascript
    const person = User.first({id: 1});
    ```

    The `where` can be anything that `knex` understands as query as well.

    + `where Object` -- The `knex` where specification.
    + `columns Array` -- List of columns to include in the returned object.
    + ___return___ `Object`
   */
  static async first(where, columns) {
    assert(where, "You must give a where options.");
    let attr = undefined;

    if(columns) {
      attr = await knex(this.table_name).column(columns).first().where(where);
    } else {
      attr = await knex(this.table_name).first().where(where);
    }

    return attr !== undefined ? new this(attr) : attr;
  }

  /*
     Delete a given record--or batch of records--based on the `where` specification.
     It really just calls this `knex` operation:

     ```javascript
     return knex(this.table_name).where(where).del();
     ```

     + `where Object` -- The where specification, doesn't have to be an Object.
     + ___return___ `number` -- Number deleted, but that might be database specific.
   */
  static async delete(where) {
    assert(where, "You must give a where options.");
    return knex(this.table_name).where(where).del();
  }

  /*
    Returns all records matching the `where` specification, and also
    reduces the returned columns based on the `columns` list. This is
    the most common operation in `knex`, and when you do both `columns`
    and `where` it's just doing this:

    ```javascript
      results = await knex(this.table_name).column(columns).where(where).select();
    ```

    As usual, if this isn't efficient or complex enough for you then you can
    just do it directly in `knex`.

    + `where Object` -- The usual `knex` where specification, and can be anything `knex` likes.
    + `columns Array` -- The list of columns to return.
    + ___return___ `Array` of `Model` subclass.
   */
  static async all(where, columns) {
    assert(where, "You must give a where options.");
    let results = [];

    if(columns) {
      results = await knex(this.table_name).column(columns).where(where).select();
    } else {
      results = await knex(this.table_name).where(where);
    }

    let final = results.map(r => new this(r));
    return final;
  }

  /*
    This is how you create your own models based on `Model`.  It's a neat
    trick as well, which allows you to specify a table to use to populate
    a new class.  First, you use it like this:

    ```javascript
    class User extends Model.from_table('user') {
    }
    ```

    How this works:

    + `from_table` crafts an empty class with `let m = class extends Model {}`.
    + Since JavaScript is a scripting language you can modify this class, and return it.
    + `from_table` then adds a `table_name` and the `schema` to this class.
    + After that it returns the new empy class, which you then extend and now you have a class pre-configured with the schema and table_name already set.

    Obviously this only works with ES6 style classes.  After this setup you just add your own
    methods, use `super` like normal, and everything else.  The functions in `Model` will all
    work because `schema` and `table_name` are set.

    + `table_name` -- the name of the base table in the database to use. Must be in `SCHEMA`.
   */
  static from_table(table_name) {
    let m = class extends Model { };
    m.table_name = table_name;

    assert(SCHEMA, "schema is not loaded!");
    assert(SCHEMA[table_name], `table named ${table_name} not in SCHEMA: ${ Object.keys(SCHEMA) }`);

    m.schema = SCHEMA[table_name]._columns;
    return m;
  }

  /*
    Determines if at least one record exists for the `where` specification.
    This is doing a select for only an `id` column with a limit of 1, and if
    it gets a result then it returns true.  Probably not the most efficient
    but it is portable. Here's what `knex` it's doing:

    ```javascript
    let res = await knex(this.table_name).select('id').where(where).limit(1).first();
    ```

    + `where Object` -- the query specification for `knex`.
    + ___return___ `boolean` -- Whether it exists or not.
   */
  static async exists(where) {
    let res = await knex(this.table_name).select('id').where(where).limit(1).first();
    return res ? res.id : false;
  }
}

export default { knex, SCHEMA, Model };
