/*
 This contains the base classes for the `Model`s in the database. It uses
 the [lib/ormish.js](/admin/#/docs/lib/ormish.js) module to build up classes for:

 + `User` -- A simple user model.
 + `Payment` -- A Payment model that is simple but can support Stripe, Paypal, and BTCPayServer.
 + `Product` -- A simple Product for the site.
 + `Media` -- Media information common to video and music files, but could work with anything.
 + `Livestream` -- Metadata for livestream sessions (see `client/pages/Livestream.svelte`).
 + `Site` -- Simple key=value store for settings you might want in your site.

 As with all other code in this project these models are _complete_ but don't have everything
 under the sun.  For example, it acts as if `User` pays one time to access the entire site and
 all `Product`.  That is one very valid model of payment, and it's the simplest one which you
 can then build on.  The rationale is you get a starter kit that you can then mold into what you
 need because this is simple enough to understand while also being useful to get started.

 ### Tests

 You should look at the tests in `tests/models` for many examples of using these.
 */
import { knex, Model } from './ormish.js';
import bcrypt from 'bcryptjs';
import {v4 as uuid} from "uuid";
import logging from "./logging.js";
import assert from "assert";
import crypto from "crypto";

const log = logging.create("lib/models.js");

/* Number of bytes in the reset code sent.  */
export const RESET_CODE_SIZE = 8;

/* Bytes in the unique unsubscribe code. */
export const UNSUB_CODE_SIZE = 16;

/*
  The `User` model contains the majority of settings for the users for the site.
  It should contain almost everything you need, and won't change much.  If you want
  to add more options and settings I recommend either create a `knex` migration to
  change it, or add a `Model.has_one` relation that uses a second table for additional
  features.


  + [user](/admin/#/table/user) - admin this table
 */
export class User extends Model.from_table('user') {

  /*
    Authenticates the user based on their `username` and `password`.
    The `username` can be anything, but I've chosen email since that seems
    to be the most common unique username method.
    It first finds that user, then it uses the [bcryptjs](https://www.npmjs.com/package/bcryptjs)
    or [bcrypt](https://www.npmjs.com/package/bcrypt) modules.

    ### bcrypt vs bcryptjs

    By default this code uses [bcryptjs](https://www.npmjs.com/package/bcryptjs) as that has
    lower installation requirements.  The big problem with all C++ based modules is the
    fagility of `node-gyp` and `node-pre-gyp`.  If you need the speed then change the import
    above to use `bcrypt` instead of `bcryptjs`, but be prepared to have possible errors
    randomly when you install it.

    ### Usage

    ```javascript
    const authenticated = await User.auth("help@learnjsthehardway.com", "notmypassword");
    ```

    + `username String` -- The "username" which is currently the `user.email` field.
    + `password String` -- The password as the user types it. _DO NOT STORE THIS._
    + ___return___ `User` or `undefined` if the user is not found or not authenticated
   */
  static async auth(username, password) {
    try {
      const uname = username.toLowerCase();
      const res = await knex('user').where({email: uname}).select();

      if(res === undefined) {
        log.error(res, "Received undefined but knex claims only Array returns.");
        return undefined;
      } else if(res.length > 1) {
        log.error(res, `Multiple accounts under the same email ${uname}`);
        return undefined;
      } else if(res.length === 0) {
        // no user found
        return undefined;
      } else {
        // user found, check password
        const user = res[0];
        const good = bcrypt.compareSync(password, user.password);
        return good ? user : undefined;
      }
    } catch(error) {
      log.error(error);
      return undefined;
    }
  }

  /*
    Performs all the cleanup and checks needed for a registration. It will
    ensure that the password and password_repeat are the same, lowercase the email,
    clean out unwanted attributes with `User.clean`, generate required keys, etc.

    + `attr Object` - attributes usually from a web form
    + ___return___ `Object`, `undefined` or the new user on success
   */
  static async register(attr) {
    let user = undefined;

    if(attr.password != attr.password_repeat) {
      return undefined;
    } else {
      user = User.clean(attr);
    }

    user.email = user.email.toLowerCase();

    // validate here?
    let exists = await User.first({email: user.email});

    if(exists) {
      log.error("User exists", user.email);
      return undefined;
    } else {
      user.password = User.encrypt_password(user.password);
      user.unsubkey = User.random_hex(UNSUB_CODE_SIZE);

      let res = await knex('user').insert(user);
      user.id = res[0];

      return res.length == 0 ? undefined : user;
    }
  }

  /*
    Uses the [crypt](https://nodejs.org/docs/latest-v18.x/api/crypto.html) module to generate a random hex string of
    size `bytes`.

    + `size Number` -- size of bytes, then converted to hex.
    + ___return___ String
   */
  static random_hex(size) {
    assert(size, `random_hex size can't be falsy ${ size }`);
    return crypto.randomBytes(size).toString("hex");
  }

  /*
    Given a string for a password this will use the `bcrypt.hashSync` function
    to encrypt it.  It generates the salt for each password it encrypts, which
    I believe is the correct way to do it.  If that's not then someone should
    update the `bcrypt` docs.

    + `password String` -- A string to password encrypt.
    + ___return___ String`
   */
  static encrypt_password(password) {
    assert(password, `Password cannot be falsy: ${ password }`);
    let salt = bcrypt.genSaltSync(10);
    return bcrypt.hashSync(password, salt);
  }

  /*
    Changes the user's unsubscribe from email setting, to determine if you
    should email them or not.  It uses the `User.unsubscribe` attribute, so
    `true` means they are ___not___ receiving emails, and `false` means they
    ___will__ receive emails.

    + `setting boolean` -- Whether they are unsubscribed or not.
    + ___return___ `number` -- Count of records changed, should be 1.
   */
  async emails(setting) {
    // don't change it if it's already set this way
    if(this.unsubscribe !== setting) {
      return await User.update({id: this.id}, {
        unsubscribe: setting, unsubscribed_on: Date.now()
      });
    } else {
      return 1;
    }
  }

  /* Has many mapping where Users have many Payments, but Payment has only one User. */
  async payments() {
    return await this.has_many(Payment, { user_id: this.id });
  }
}

/*
  Represents a payment in any system with Stripe, BTCPayServer, and Paypal
  currently supported.  It contains `sys_primary_id` and `sys_secondary_id`
  fields for generic "id" fields that these services love to throw around, with
  an `internal_id` for our tracking.  Every payment processor has different uses
  for the various IDs returned, so refer to the `/api/payments/` modules for how
  these are mapped.

  + [payment](/admin/#/table/payment) - admin this table
 */
export class Payment extends Model.from_table('payment') {

  /* Payment has only one User, but User has many Payments. */
  get user() {
    return this.user_id !== undefined ? this.has_one(User, { id: this.user_id }) : undefined;
  }

  /*
    Used in testing and debugging to create a fake payment with no specific
    system.  Look in `tests/models/payment.js` to see how this works:

    ```javascript
    let test1 = await Payment.fake_payment();
    ```

    + ___return___ A fake `Payment` object for testing.
  */
  static fake_payment() {
    return Payment.insert({
      system: 'fake',
      status: 'complete',
      internal_id: Payment.gen_internal_id(),
      sys_primary_id: Payment.gen_internal_id(),
      sys_secondary_id: Payment.gen_internal_id(),
      sys_created_on: new Date()
    });
  }

  /*
   Generates a random `uuid()` for use in the `internal_id` field.
   _WARNING_: It's not clear if the `uuid()` function is actually
   secure or not, so don't rely on this for any cryptography.
  */
  static gen_internal_id() {
    return uuid();
  }

  /*
   Indicates whether the `user` has paid for the site's product.  Keep in mind
   that this is using a model of a _single_ payment for access to the site.  As
   with all of The Bandolier it's not a full featured payment system, but it is
   enough to get started.  If you wanted to support subscriptions this would
   most likely work, but if you want to sell multiple products or create a full
   store then you've got some work to do.

   Another thing that this model won't support is the "merchant" experience, where
   you let other people sell their stuff on your site.  That is the _most_ complex
   online business to run as it requires receiving money and giving money to other
   people.

   ### Study This

   This contains a unique use of the `lib/ormish.js:Model.first` and `knex` to
   construct a complex query with a custom builder:

   ```javascript
    // ignore refunded payments
    const payment = await Payment.first(builder => {
      builder
        .whereNotIn("status", ["refunded", "failed", "pending"])
        .where({user_id: user.id})
    }, ["user_id", "system", "status", "status_reason"]);
    ```

    This doesn't come up too often, but when you need it this little snippet
    is gold.  The idea is that `Model.first` can accept anything that `knex`
    accepts, which includes a builder callback to create a refined query.

   + `user User` - The user to confirm payment on.
   + ___return__ `[true, Payment]` if paid or `[false, undefined]` if not paid.
  */
  static async paid(user) {
    assert(user !== undefined, "Invalid user given to Payment.paid");
    assert(user.id !== undefined, "User object given has an user.id === undefined.");

    // ignore refunded payments
    const payment = await Payment.first(builder => {
      builder
        .whereNotIn("status", ["refunded", "failed", "pending"])
        .where({user_id: user.id})
    }, ["user_id", "system", "status", "status_reason"]);

    // it's paid if there's a payment and it is complete
    const paid = payment !== undefined && payment.status === "complete";

    // this now returns paid and also paid is false with more info about why
    return [paid, payment];
  }
}

/*
  Simple `media` table for storing metadata on various media you
  will probably need to display.  It's debatable whether this should
  be in the database or simply output to straight `.json` files.  The
  database is easier to edit, but `.json` files are way easier to host
  and transmit.  I may come up with a hybrid model eventually.

  + [media](/admin/#/table/media) - admin this table
 */
export class Media extends Model.from_table("media") {
}

/*
  A useful simle key=value table for storing various settings
  and stats for the site.  It contains a `set`, `get`, `increment`, and
  `decrement` methods for efficient(ish) counting events or
  actions.  I think if you want to do this at significant scale
  get a time series database. For simple things this works.

  + [site](/admin/#/table/site) - admin this table
 */
export class Site extends Model.from_table("site") {

  /*
    Get the value for the given key.  This is a `json` type
    in the database, so YMMV if your database isn't SQlite3.

    + `key string` -- The key in the database.
    + ___return___ `Object` -- Uses JSON.parse(), so not really tested on other databases.
   */
  static async get(key) {
    const row = await knex(this.table_name).first().where({key});
    return  row !== undefined ? JSON.parse(row.value) : undefined;
  }

  /*
    Set a key to a value, which is a generic JSON object type.  This is
    the inverse of `get()`.  You really need to think what goes in here.
    If it's very large you'll probably want to look at a document database
    or store it on the disk.


    + `key string` -- The key to set.
    + `value Any JSON type` - This is run through `JSON.stringify`.
    + ___return___ See `lib/ormish.js:Model.upsert` for what is returned.
   */
  static async set(key, value) {
    const json_value = JSON.stringify(value);
    return await Site.upsert({key, value: json_value}, "key");
  }

  /*
    Performs a SQL increment operation, but it's dubious whether this
    works outside of SQLite3.  I believe since SQLite3 technically
    stores everything as a string it mostly ignores the `json` type
    of the column and treats it like an integer.

    The reason you need this operation is it's more efficient to blindly
    tell the database to add to a counter it knows, then to select that number,
    add to it, then set it again.

    ___TODO___: figure out how to get sqlite3 to do a return of the value.

    + `key string` - The key to set.
    + `count number` - How much to increment.
   */
  static async increment(key, count) {
    return await knex(this.table_name).where({key}).increment("value", count);
  }

  /*
    Same as `increment()` but the inverse. Same parameters.
   */
  static async decrement(key, count) {
    return await knex(this.table_name).where({key}).decrement("value", count);
  }
}

/*
  Supports Livestream information, which includes viewer stats, scheduling information
  and other features you need when doing a livestream.

  + [livestream](/admin/#/table/livestream) - admin this table
 */
export class Livestream extends Model.from_table("livestream") {

  /*
    Return the `Media` attached to this (1:1) `Livestream`. This is set
    after the stream is done and is stored for replay later.
   */
  media() {
    return Media.first({id: this.media_id});
  }

  /*
    Increments the viewer count of an arbitrary `Livestream` by its `id`.

    + `id number` -- `Livestream` to increment.
    + ___return___ Currently should return whatever `knex`'s `increment` returns.
   */
  static add_viewers(id) {
    return knex(this.table_name).where({id}).increment('viewer_count', 1);
  }
}

/*
  Represents a Product in the system.  This is very generic and meant to be something
  you change.  In my [learnjsthehardway.com](https://learnjsthehardway.com) site it's
  called `Course` instead so you might see some leftover from the conversion.  I changed
  it since not everyone has a course in their system, but most people have a product.

  You'll also notice this has no connection to `Purchase`.  That's because this demo
  site is simple and there's just one `Payment` per user to give access to all `Product`s.
  In most other sites you'd create a `Purchase` object that would tie together the `User`,
  `Product`, and `Payment` each time they bought things.

  + [product](/admin/#/table/product) - admin this table
 */
export class Product extends Model.from_table('product') {
}
