const path = require('path');
const Knex = require('knex');
const locks = require('locks');
const pb = require('../../protobuf');

const MIGRATIONS_DIR = path.join(__dirname, 'migrations');

const DefaultOptions = {
  filename: ':memory:'
};

class StatementDB {

  constructor(options = DefaultOptions) {
    if (options == null) options = DefaultOptions;

    this._db = Knex({
      client: 'sqlite3',
      connection: {
        filename: options.filename
      },
      useNullAsDefault: true
    });
    this._migrated = false;
    this._migrationLock = locks.createMutex();
  }

  /**
   * Accessor for the underlying sql DB (via knex query builder).
   * Use this instead of directly accessing the `_db` instance,
   * to ensure that the db migrations are run before accessing
   * the db.
   * @returns {*}
   */
  sqlDB() {
    if (this._migrated) return Promise.resolve(this._db);

    return new Promise(resolve => this._migrationLock.lock(() => {
      if (this._migrated) return resolve(this._db);
      this._db.migrate.latest({ directory: MIGRATIONS_DIR }).then(() => {
        this._migrated = true;
        this._migrationLock.unlock();
        resolve(this._db);
      });
    }));
  }

  put(stmt) {
    return this.sqlDB().then(db => {
      const data = pb.stmt.Statement.encode(stmt);
      const { id, namespace, publisher, timestamp } = stmt;
      const refs = Array.from(statementRefs(stmt));
      return db.transaction(
      // insert statement data
      tx => tx.insert({ id, data }).into('Statement')
      // insert envelope
      .then(() => tx.insert({ id, namespace, publisher, source: statementSource(stmt), timestamp }).into('Envelope'))
      // insert all refs
      .then(() => Promise.all(refs.map(wki => tx.insert({ id, wki }).into('Refs')))));
    });
  }

  get(id) {
    return this.sqlDB().then(db => db.table('Statement').first('data').where('id', id)).then(decodeStatementRow);
  }

  getByWKI(wki) {
    return this.sqlDB().then(db => db.table('Statement').join('Refs', 'Statement.id', 'Refs.id').select('Statement.data').where('Refs.wki', wki)).then(rows => rows.map(decodeStatementRow));
  }

  getByNamespace(ns) {
    return this.sqlDB().then(db => db.table('Statement').join('Envelope', 'Statement.id', 'Envelope.id').select('Statement.data').whereRaw(namespaceCriteria('Envelope.namespace', ns))).then(rows => rows.map(decodeStatementRow));
  }
}

function decodeStatementRow(row) {
  return pb.stmt.Statement.decode(row.data);
}

function namespaceCriteria(field, ns) {
  if (ns === '*') {
    return `${ field } != ''`;
  }
  const starIndex = ns.indexOf('*');
  if (starIndex < 0) {
    return `${ field } = '${ ns }'`;
  }
  const prefix = ns.substr(0, starIndex - 1);
  return `${ field } LIKE '%${ prefix }%%'`;
}

// TODO: move these helpers elsewhere

function statementSource(stmt) {
  if (stmt.body.envelope !== undefined) {
    const envelopeStatement = stmt.body.envelope; // stupid flow typecast
    if (envelopeStatement.body.length > 0) {
      return statementSource(envelopeStatement.body[0]);
    }
  }
  return stmt.publisher;
}

function statementRefs(stmt) {
  if (stmt.body.simple !== undefined) {
    return simpleStmtRefs(stmt.body.simple);
  }
  if (stmt.body.compound !== undefined) {
    return compoundStmtRefs(stmt.body.compound);
  }
  if (stmt.body.envelope !== undefined) {
    return envelopeStmtRefs(stmt.body.envelope);
  }
  throw new Error('Invalid statement type (expected simple, compound, or envelope)');
}

function simpleStmtRefs(stmt) {
  return new Set(stmt.refs);
}

function compoundStmtRefs(stmt) {
  let allRefs = new Set();
  for (const simpleStmt of stmt.body) {
    allRefs = union(allRefs, simpleStmtRefs(simpleStmt));
  }
  return allRefs;
}

function envelopeStmtRefs(stmt) {
  let allRefs = new Set();
  for (const innerStmt of stmt.body) {
    allRefs = union(allRefs, statementRefs(innerStmt));
  }
  return allRefs;
}

function union(a, b) {
  const u = new Set(a);
  for (const elem of b) {
    u.add(elem);
  }
  return u;
}

module.exports = {
  StatementDB
};