'use strict';

import {
  DOC_STORE,
  idbError,
  rawIndexFields,
  isPartialFilterView,
  naturalIndexName
} from './util';

import {
  IDB_NULL,
  IDB_TRUE,
  IDB_FALSE,
} from './rewrite';

// Adapted from
// https://github.com/pouchdb/pouchdb/blob/master/packages/node_modules/pouchdb-find/src/adapters/local/find/query-planner.js#L20-L24
// This could change / improve in the future?
const COUCH_COLLATE_LO = null;
const COUCH_COLLATE_HI = '\uffff'; // actually used as {"\uffff": {}}

// Adapted from: https://www.w3.org/TR/IndexedDB/#compare-two-keys
// Importantly, *there is no upper bound possible* in idb. The ideal data
// structure an infinitely deep array:
//   const IDB_COLLATE_HI = []; IDB_COLLATE_HI.push(IDB_COLLATE_HI)
// But IDBKeyRange is not a fan of shenanigans, so I've just gone with 12 layers
// because it looks nice and surely that's enough!
const IDB_COLLATE_LO = Number.NEGATIVE_INFINITY;
const IDB_COLLATE_HI = [[[[[[[[[[[[]]]]]]]]]]]];

//
// TODO: this should be made offical somewhere and used by AllDocs / get /
// changes etc as well.
//
function externaliseRecord(idbDoc) {
  const doc = idbDoc.revs[idbDoc.rev].data;
  doc._id = idbDoc.id;
  doc._rev = idbDoc.rev;
  if (idbDoc.deleted) {
    doc._deleted = true;
  }

  return doc;
}

/**
 * Generates a keyrange based on the opts passed to query
 *
 * The first key is always 0, as that's how we're filtering out deleted entries.
 */
function generateKeyRange(opts) {
  function defined(obj, k) {
    return obj[k] !== void 0;
  }

  // Converts a valid CouchDB key into a valid IndexedDB one
  function convert(key, exact) {
    // The first item in every native index is doc.deleted, and we always want
    // to only search documents that are not deleted.
    // "foo" -> [0, "foo"]
    const filterDeleted = [0].concat(key);

    return filterDeleted.map(function (k) {
      // null, true and false are not indexable by indexeddb. When we write
      // these values we convert them to these constants, and so when we
      // query for them we need to convert the query also.
      if (k === null && exact) {
        // for non-exact queries we treat null as a collate property
        // see `if (!exact)` block below
        return IDB_NULL;
      } else if (k === true) {
        return IDB_TRUE;
      } else if (k === false) {
        return IDB_FALSE;
      }

      if (!exact) {
        // We get passed CouchDB's collate low and high values, so for non-exact
        // ranged queries we're going to convert them to our IDB equivalents
        if (k === COUCH_COLLATE_LO) {
          return IDB_COLLATE_LO;
        } else if (Object.prototype.hasOwnProperty.call(k, COUCH_COLLATE_HI)) {
          return IDB_COLLATE_HI;
        }
      }

      return k;
    });
  }

  // CouchDB and so PouchdB defaults to true. We need to make this explicit as
  // we invert these later for IndexedDB.
  if (!defined(opts, 'inclusive_end')) {
    opts.inclusive_end = true;
  }
  if (!defined(opts, 'inclusive_start')) {
    opts.inclusive_start = true;
  }

  if (opts.descending) {
    // Flip before generating. We'll check descending again later when performing
    // an index request
    const realEndkey = opts.startkey,
        realInclusiveEnd = opts.inclusive_start;

    opts.startkey = opts.endkey;
    opts.endkey = realEndkey;
    opts.inclusive_start = opts.inclusive_end;
    opts.inclusive_end = realInclusiveEnd;
  }

  try {
    if (defined(opts, 'key')) {
      return IDBKeyRange.only(convert(opts.key, true));
    }

    if (defined(opts, 'startkey') && !defined(opts, 'endkey')) {
      // lowerBound, but without the deleted docs.
      // [1] is the start of the deleted doc range, and we don't want to include then.
      return IDBKeyRange.bound(
        convert(opts.startkey), [1],
        !opts.inclusive_start, true
      );
    }

    if (!defined(opts, 'startkey') && defined(opts, 'endkey')) {
      return IDBKeyRange.upperBound(convert(opts.endkey), !opts.inclusive_end);
    }

    if (defined(opts, 'startkey') && defined(opts, 'endkey')) {
      return IDBKeyRange.bound(
        convert(opts.startkey),    convert(opts.endkey),
        !opts.inclusive_start, !opts.inclusive_end
      );
    }

    return IDBKeyRange.only([0]);
  } catch (err) {
    console.error('Could not generate keyRange', err, opts);
    throw Error('Could not generate key range with ' + JSON.stringify(opts));
  }
}

function getIndexHandle(pdb, fields, reject) {
  const indexName = naturalIndexName(fields);

  return new Promise(function (resolve) {
    pdb._openTransactionSafely([DOC_STORE], 'readonly', function (err, txn) {
      if (err) {
        return idbError(reject)(err);
      }

      txn.onabort = idbError(reject);
      txn.ontimeout = idbError(reject);

      const existingIndexNames = Array.from(txn.objectStore(DOC_STORE).indexNames);

      if (existingIndexNames.indexOf(indexName) === -1) {
        // The index is missing, force a db restart and try again
        pdb._freshen()
          .then(function () { return getIndexHandle(pdb, fields, reject); })
          .then(resolve);
      } else {
        resolve(txn.objectStore(DOC_STORE).index(indexName));
      }
    });
  });
}

// In theory we should return something like the doc example below, but find
// only needs rows: [{doc: {...}}], so I think we can just not bother for now
// {
//   "offset" : 0,
//   "rows": [{
//     "id": "doc3",
//     "key": "Lisa Says",
//     "value": null,
//     "doc": {
//       "_id": "doc3",
//       "_rev": "1-z",
//       "title": "Lisa Says"
//     }
//   }],
//   "total_rows" : 4
// }
function query(idb, signature, opts, fallback) {
  // At this stage, in the current implementation, find has already gone through
  // and determined if the index already exists from PouchDB's perspective (eg
  // there is a design doc for it).
  //
  // If we find that the index doesn't exist this means we have to close and
  // re-open the DB to correct indexes before proceeding, at which point the
  // index should exist.

  const pdb = this;

  // Assumption, there will be only one /, between the design document name
  // and the view name.
  const parts = signature.split('/');

  return new Promise(function (resolve, reject) {
    pdb.get('_design/' + parts[0]).then(function (ddoc) {
      if (isPartialFilterView(ddoc, parts[1])) {
        // Fix for #8522
        // An IndexedDB index is always over all entries. And there is no way to filter them.
        // Therefore the normal findAbstractMapper will be used
        // for indexes with partial_filter_selector.
        return fallback(signature, opts).then(resolve, reject);
      }

      const fields = rawIndexFields(ddoc, parts[1]);
      if (!fields) {
        throw new Error('ddoc ' + ddoc._id +' with view ' + parts[1] +
          ' does not have map.options.def.fields defined.');
      }

      let skip = opts.skip;
      let limit = Number.isInteger(opts.limit) && opts.limit;

      return getIndexHandle(pdb, fields, reject)
        .then(function (indexHandle) {
          const keyRange = generateKeyRange(opts);
          const req = indexHandle.openCursor(keyRange, opts.descending ? 'prev' : 'next');

          const rows = [];
          req.onerror = idbError(reject);
          req.onsuccess = function (e) {
            const cursor = e.target.result;

            if (!cursor || limit === 0) {
              return resolve({
                rows
              });
            }

            if (skip) {
              cursor.advance(skip);
              skip = false;
              return;
            }

            if (limit) {
              limit = limit - 1;
            }

            rows.push({doc: externaliseRecord(cursor.value)});
            cursor.continue();
          };
        });
      })
      .catch(reject);
  });

}

function viewCleanup(idb, fallback) {
  // I'm not sure we have to do anything here.
  //
  // One option is to just close and re-open the DB, which performs the same
  // action. The only reason you'd want to call this is if you deleted a bunch
  // of indexes and wanted the space back immediately.
  //
  // Otherwise index cleanup happens when:
  //  - A DB is opened
  //  - A find query is performed against an index that doesn't exist but should

  // Fix for #8522
  // On views with partial_filter_selector the standard find-abstract-mapper is used.
  // Its indexes must be cleaned up.
  // Fallback is the standard viewCleanup.
  return fallback();
}

export { query, viewCleanup };
