import forOwn from 'lodash/forOwn';
import find from 'lodash/find';

import Session from './Session';
import Model from './Model';
import { createDatabase as defaultCreateDatabase } from './db';
import {
    ForeignKey,
    ManyToMany,
} from './fields';

import {
    m2mName,
    attachQuerySetMethods,
    m2mToFieldName,
    m2mFromFieldName,
} from './utils';

const ORM_DEFAULTS = {
    createDatabase: defaultCreateDatabase,
};

/**
 * Schema's responsibility is tracking the set of {@link Model} classes used in the database.
 * To include your model in that set, Schema offers {@link Schema#register} and a
 * shortcut {@link Schema#define} methods.
 *
 * Schema also handles starting a Session with {@link Schema#from}.
 */
const ORM = class ORM {
    /**
     * Creates a new Schema.
     */
    constructor(opts) {
        const { createDatabase } = Object.assign({}, ORM_DEFAULTS, (opts || {}));
        this.createDatabase = createDatabase;
        this.registry = [];
        this.implicitThroughModels = [];
        this.installedFields = {};
    }

    /**
     * Registers a {@link Model} class to the schema.
     *
     * If the model has declared any ManyToMany fields, their
     * through models will be generated and registered with
     * this call.
     *
     * @param  {...Model} model - a {@link Model} class to register
     * @return {undefined}
     */
    register(...models) {
        models.forEach(model => {
            model.invalidateClassCache();

            this.registerManyToManyModelsFor(model);
            this.registry.push(model);
        });
    }

    registerManyToManyModelsFor(model) {
        const fields = model.fields;
        const thisModelName = model.modelName;

        forOwn(fields, (fieldInstance, fieldName) => {
            if (fieldInstance instanceof ManyToMany && !fieldInstance.through) {
                let toModelName;
                if (fieldInstance.toModelName === 'this') {
                    toModelName = thisModelName;
                } else {
                    toModelName = fieldInstance.toModelName;
                }

                const fromFieldName = m2mFromFieldName(thisModelName);
                const toFieldName = m2mToFieldName(toModelName);

                const Through = class ThroughModel extends Model {};

                Through.modelName = m2mName(thisModelName, fieldName);

                Through.fields = {
                    [fromFieldName]: new ForeignKey(thisModelName),
                    [toFieldName]: new ForeignKey(toModelName),
                };

                Through.invalidateClassCache();
                this.implicitThroughModels.push(Through);
            }
        });
    }

    /**
     * Gets a {@link Model} class by its name from the registry.
     * @param  {string} modelName - the name of the {@link Model} class to get
     * @throws If {@link Model} class is not found.
     * @return {Model} the {@link Model} class, if found
     */
    get(modelName) {
        const found = find(
            this.registry.concat(this.implicitThroughModels),
            (model) => model.modelName === modelName
        );

        if (typeof found === 'undefined') {
            throw new Error(`Did not find model ${modelName} from registry.`);
        }
        return found;
    }

    getModelClasses() {
        this._setupModelPrototypes(this.registry);
        this._setupModelPrototypes(this.implicitThroughModels);
        return this.registry.concat(this.implicitThroughModels);
    }

    _attachQuerySetMethods(model) {
        const { querySetClass } = model;
        attachQuerySetMethods(model, querySetClass);
    }

    isFieldInstalled(modelName, fieldName) {
        return this.installedFields.hasOwnProperty(modelName)
            ? !!this.installedFields[modelName][fieldName]
            : false;
    }

    setFieldInstalled(modelName, fieldName) {
        if (!this.installedFields.hasOwnProperty(modelName)) {
            this.installedFields[modelName] = {};
        }
        this.installedFields[modelName][fieldName] = true;
    }

    _setupModelPrototypes(models) {
        models.forEach(model => {
            if (!model.isSetUp) {
                const fields = model.fields;
                forOwn(fields, (fieldInstance, fieldName) => {
                    if (!this.isFieldInstalled(model.modelName, fieldName)) {
                        fieldInstance.install(model, fieldName, this);
                        this.setFieldInstalled(model.modelName, fieldName);
                    }
                });
                this._attachQuerySetMethods(model);
                model.isSetUp = true;
            }
        });
    }

    generateSchemaSpec() {
        const models = this.getModelClasses();
        const tables = models.reduce((spec, modelClass) => {
            const tableName = modelClass.modelName;
            const tableSpec = modelClass._getTableOpts();
            spec[tableName] = Object.assign({}, tableSpec, { fields: modelClass.fields });
            return spec;
        }, {});
        return { tables };
    }

    getDatabase() {
        if (!this.db) {
            this.db = this.createDatabase(this.generateSchemaSpec());
        }
        return this.db;
    }

    /**
     * Returns the default state.
     * @return {Object} the default state
     */
    getEmptyState() {
        return this.getDatabase().getEmptyState();
    }

    /**
     * Begins an immutable database session.
     *
     * @param  {Object} state  - the state the database manages
     * @return {Session} a new {@link Session} instance
     */
    session(state) {
        return new Session(this, this.getDatabase(), state);
    }

    // Alias for session.
    from(state) {
        return this.session(state);
    }

    /**
     * Begins a mutable database session.
     *
     * @param  {Object} state  - the state the database manages
     * @return {Session} a new {@link Session} instance
     */
    mutableSession(state) {
        return new Session(this, this.getDatabase(), state, true);
    }
};

export default ORM;

@@ -440,262 +440,335 @@
 %0A * 
-Schema's responsibility is tracking the set of %7B@link Model%7D classes used in the database.%0A * To include your model in that set, Schema offers %7B@link Schema#register%7D and a%0A * shortcut %7B@link Schema#define%7D methods.%0A *%0A * Schema also handles s
+ORM - the Object Relational Mapper.%0A *%0A * Use instances of this class to:%0A *%0A * - Register your %7B@link Model%7D classes using %7B@link ORM#register%7D%0A * - Get the empty state for the underlying database with %7B@link ORM#getEmptyState%7D%0A * - Start an immutable database session with %7B@link ORM#session%7D%0A * - Start a mu
 ta
-r
 ting 
-a S
+database s
 essi
@@ -786,20 +786,129 @@
 ink 
-Schema#from%7D
+ORM#mutableSession%7D%0A *%0A * Internally, this class handles generating a schema specification from models%0A * to the database
 .%0A *
@@ -962,22 +962,28 @@
 s a new 
-Schema
+ORM instance
 .%0A     *
@@ -1301,22 +1301,19 @@
  to the 
-schema
+ORM
 .%0A     *
@@ -1451,16 +1451,66 @@
 his call
+, unless a custom through model has been specified
 .%0A     *
@@ -5516,31 +5516,38 @@
 Returns the 
-default
+empty database
  state.%0A    
@@ -5570,23 +5570,21 @@
 ct%7D the 
-default
+empty
  state%0A 

Update docstrings for ORM