/*$AMPERSAND_VERSION*/
var State = require('ampersand-state');
var CollectionView = require('ampersand-collection-view');
var domify = require('domify');
var uniqueId = require("lodash/uniqueId");
var pick = require("lodash/pick");
var assign = require("lodash/assign");
var forEach = require("lodash/forEach");
var result = require("lodash/result");
var last = require("lodash/last");
var isString = require("lodash/isString");
var bind = require("lodash/bind");
var flatten = require("lodash/flatten");
var invokeMap = require("lodash/invokeMap");
var events = require('events-mixin');
var matches = require('matches-selector');
var bindings = require('ampersand-dom-bindings');
var getPath = require('lodash/get');

function View(attrs) {
    this.cid = uniqueId('view');
    attrs || (attrs = {});
    var parent = attrs.parent;
    delete attrs.parent;
    BaseState.call(this, attrs, {init: false, parent: parent});
    this.on('change:el', this._handleElementChange, this);
    this._upsertBindings();
    this.template = attrs.template || this.template;
    this._cache.rendered = false; // prep `rendered` derived cache immediately
    this.initialize.apply(this, arguments);
    if (this.autoRender && this.template) {
        this.render();
    }
}

var BaseState = State.extend({
    dataTypes: {
        element: {
            set: function (newVal) {
                return {
                    val: newVal,
                    type: newVal instanceof Element ? 'element' : typeof newVal
                };
            },
            compare: function (el1, el2) {
                return el1 === el2;
            }
        },
        collection: {
            set: function (newVal) {
                return {
                    val: newVal,
                    type: newVal && newVal.isCollection ? 'collection' : typeof newVal
                };
            },
            compare: function (currentVal, newVal) {
                return currentVal === newVal;
            }
        }
    },
    props: {
        model: 'state',
        el: 'element',
        collection: 'collection',
    },
    session: {
        _rendered: ['boolean', true, false]
    },
    derived: {
        hasData: {
            deps: ['model'],
            fn: function () {
                return !!this.model;
            }
        },
        rendered: {
            deps: ['_rendered'],
            fn: function() {
                if (this._rendered) {
                    this.trigger('render', this);
                    return true;
                }
                this.trigger('remove', this);
                return false;
            }
        }
    }
});

View.prototype = Object.create(BaseState.prototype);

var queryNoElMsg = 'Query cannot be performed as this.el is not defined. Ensure that the view has been rendered.';

// Set up all inheritable properties and methods.
assign(View.prototype, {
    // ## query
    // Get an single element based on CSS selector scoped to this.el
    // if you pass an empty string it return `this.el`.
    // If you pass an element we just return it back.
    // This lets us use `get` to handle cases where users
    // can pass a selector or an already selected element.
    query: function (selector) {
        if (!this.el) {
            throw new Error(queryNoElMsg);
        }
        if (!selector) return this.el;
        if (typeof selector === 'string') {
            if (matches(this.el, selector)) return this.el;
            return this.el.querySelector(selector) || undefined;
        }
        return selector;
    },

    // ## queryAll
    // Returns an array of elements based on CSS selector scoped to this.el
    // if you pass an empty string it return `this.el`. Also includes root
    // element.
    queryAll: function (selector) {
        if (!this.el) {
            throw new Error(queryNoElMsg);
        }
        if (!selector) return [this.el];
        var res = [];
        if (matches(this.el, selector)) res.push(this.el);
        return res.concat(Array.prototype.slice.call(this.el.querySelectorAll(selector)));
    },

    // ## queryByHook
    // Convenience method for fetching element by it's `data-hook` attribute.
    // Also tries to match against root element.
    // Also supports matching 'one' of several space separated hooks.
    queryByHook: function (hook) {
        return this.query('[data-hook~="' + hook + '"]');
    },

    // ## queryAllByHook
    // Convenience method for fetching all elements by their's `data-hook` attribute.
    queryAllByHook: function (hook) {
        return this.queryAll('[data-hook~="' + hook + '"]');
    },

    // Initialize is an empty function by default. Override it with your own
    // initialization logic.
    initialize: function () {},

    // **render** is the core function that your view can override. Its job is
    // to populate its element (`this.el`), with the appropriate HTML.
    _render: function () {
        this._upsertBindings();
        this.renderWithTemplate(this);
        this._rendered = true;
        return this;
    },

    // Removes this view by taking the element out of the DOM, and removing any
    // applicable events listeners.
    _remove: function () {
        if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
        this._rendered = false;
        this._downsertBindings();
        return this;
    },

    // Change the view's element (`this.el` property), including event
    // re-delegation.
    _handleElementChange: function (element, delegate) {
        if (this.eventManager) this.eventManager.unbind();
        this.eventManager = events(this.el, this);
        this.delegateEvents();
        this._applyBindingsForKey();
        return this;
    },

    // Set callbacks, where `this.events` is a hash of
    //
    // *{"event selector": "callback"}*
    //
    //     {
    //       'mousedown .title':  'edit',
    //       'click .button':     'save',
    //       'click .open':       function (e) { ... }
    //     }
    //
    // pairs. Callbacks will be bound to the view, with `this` set properly.
    // Uses event delegation for efficiency.
    // Omitting the selector binds the event to `this.el`.
    // This only works for delegate-able events: not `focus`, `blur`, and
    // not `change`, `submit`, and `reset` in Internet Explorer.
    delegateEvents: function (events) {
        if (!(events || (events = result(this, 'events')))) return this;
        this.undelegateEvents();
        for (var key in events) {
            this.eventManager.bind(key, events[key]);
        }
        return this;
    },

    // Clears all callbacks previously bound to the view with `delegateEvents`.
    // You usually don't need to use this, but may wish to if you have multiple
    // Backbone views attached to the same DOM element.
    undelegateEvents: function () {
        this.eventManager.unbind();
        return this;
    },

    // ## registerSubview
    // Pass it a view. This can be anything with a `remove` method
    registerSubview: function (view) {
        // Storage for our subviews.
        this._subviews = this._subviews || [];
        this._subviews.push(view);
        // set the parent reference if it has not been set
        if (!view.parent) view.parent = this;
        return view;
    },

    // ## renderSubview
    // Pass it a view instance and a container element
    // to render it in. It's `remove` method will be called
    // when the parent view is destroyed.
    renderSubview: function (view, container) {
        if (typeof container === 'string') {
            container = this.query(container);
        }
        if (!container) container = this.el;
        this.registerSubview(view);
        view.render();
        container.appendChild(view.el);
        return view;
    },

    _applyBindingsForKey: function (name) {
        if (!this.el) return;
        var fns = this._parsedBindings.getGrouped(name);
        var item;
        for (item in fns) {
            fns[item].forEach(function (fn) {
                fn(this.el, getPath(this, item), last(item.split('.')));
            }, this);
        }
    },

    _initializeBindings: function () {
        if (!this.bindings) return;
        this.on('all', function (eventName) {
            if (eventName.slice(0, 7) === 'change:') {
                this._applyBindingsForKey(eventName.split(':')[1]);
            }
        }, this);
    },

    // ## _initializeSubviews
    // this is called at setup and grabs declared subviews
    _initializeSubviews: function () {
        if (!this.subviews) return;
        for (var item in this.subviews) {
            this._parseSubview(this.subviews[item], item);
        }
    },

    // ## _parseSubview
    // helper for parsing out the subview declaration and registering
    // the `waitFor` if need be.
    _parseSubview: function (subview, name) {
        //backwards compatibility with older versions, when `container` was a valid property (#114)
        if (subview.container) {
            subview.selector = subview.container;
        }
        var opts = this._parseSubviewOpts(subview);

        function action() {
            var el, subview;
            // if not rendered or we can't find our element, stop here.
            if (!this.el || !(el = this.query(opts.selector))) return;
            if (!opts.waitFor || getPath(this, opts.waitFor)) {
                subview = this[name] = opts.prepareView.call(this, el);
                if (!subview.el) {
                    this.renderSubview(subview, el);
                } else {
                    subview.render();
                    this.registerSubview(subview);
                }
                this.off('change', action);
            }
        }
        // we listen for main `change` items
        this.on('change', action, this);
    },

    // Parses the declarative subview definition.
    // You may overload this method to create your own declarative subview style.
    // You must return an object with members 'selector', 'waitFor' and 'prepareView'.
    // waitFor is trigged on the view 'change' event and so one way to extend the deferred view
    // construction is to add an additional property (props) to the view. Then setting this property
    // will satisfy the waitFor condition. You can then extend the prepareView function to pass in
    // additional data from the parent view. This can allow you to have multi-stage rendering of
    // custom data formats and to declaratively define.
    _parseSubviewOpts: function (subview) {
        var self = this;
        var opts = {
            selector: subview.selector || '[data-hook="' + subview.hook + '"]',
            waitFor: subview.waitFor || '',
            prepareView: subview.prepareView || function () {
                return new subview.constructor({
                    parent: self
                });
            }
        };
        return opts;
    },

    // Shortcut for doing everything we need to do to
    // render and fully replace current root element.
    // Either define a `template` property of your view
    // or pass in a template directly.
    // The template can either be a string or a function.
    // If it's a function it will be passed the `context`
    // argument.
    renderWithTemplate: function (context, templateArg) {
        var template = templateArg || this.template;
        if (!template) throw new Error('Template string or function needed.');
        var newDom = isString(template) ? template : template.call(this, context || this);
        if (isString(newDom)) newDom = domify(newDom);
        var parent = this.el && this.el.parentNode;
        if (parent) parent.replaceChild(newDom, this.el);
        if (newDom.nodeName === '#document-fragment') throw new Error('Views can only have one root element, including comment nodes.');
        this.el = newDom;
        return this;
    },

    // ## cacheElements
    // This is a shortcut for adding reference to specific elements within your view for
    // access later. This avoids excessive DOM queries and makes it easier to update
    // your view if your template changes.
    //
    // In your `render` method. Use it like so:
    //
    //     render: function () {
    //       this.basicRender();
    //       this.cacheElements({
    //         pages: '#pages',
    //         chat: '#teamChat',
    //         nav: 'nav#views ul',
    //         me: '#me',
    //         cheatSheet: '#cheatSheet',
    //         omniBox: '#awesomeSauce'
    //       });
    //     }
    //
    // Then later you can access elements by reference like so: `this.pages`, or `this.chat`.
    cacheElements: function (hash) {
        for (var item in hash) {
            this[item] = this.query(hash[item]);
        }
        return this;
    },

    // ## animateRemove
    // Placeholder for if you want to do something special when they're removed.
    // For example fade it out, etc.
    // Any override here should call `.remove()` when done.
    animateRemove: function () {
        this.remove();
    },

    // ## renderCollection
    // Method for rendering a collections with individual views.
    // Just pass it the collection, and the view to use for the items in the
    // collection. The collectionView is returned.
    renderCollection: function (collection, ViewClass, container, opts) {
        var containerEl = (typeof container === 'string') ? this.query(container) : container;
        var config = assign({
            collection: collection,
            el: containerEl || this.el,
            view: ViewClass,
            parent: this,
            viewOptions: {
                parent: this
            }
        }, opts);
        var collectionView = new CollectionView(config);
        collectionView.render();
        return this.registerSubview(collectionView);
    },

    _setRender: function(obj) {
        Object.defineProperty(obj, 'render', {
            get: function() {
                return this._render;
            },
            set: function(fn) {
                this._render = function() {
                    this._upsertBindings();
                    fn.apply(this, arguments);
                    this._rendered = true;
                    return this;
                };
            }
        });
    },

    _setRemove: function(obj) {
        Object.defineProperty(obj, 'remove', {
            get: function() {
                return this._remove;
            },
            set: function(fn) {
                this._remove = function() {
                    fn.apply(this, arguments);
                    this._rendered = false;
                    this._downsertBindings();
                    return this;
                };
            }
        });
    },

    _downsertBindings: function() {
        var parsedBindings = this._parsedBindings;
        if (!this.bindingsSet) return;
        if (this._subviews){
          invokeMap(flatten(this._subviews), 'remove');
          this._subviews = [];
        }
        this.stopListening();
        this.bindingsSet = false;
    },

    _upsertBindings: function(attrs) {
        attrs = attrs || this;
        if (this.bindingsSet) return;
        this._parsedBindings = bindings(this.bindings, this);
        this._initializeBindings();
        if (attrs.el && !this.autoRender) {
            this._handleElementChange();
        }
        this._initializeSubviews();
        this.bindingsSet = true;
    }
});

View.prototype._setRender(View.prototype);
View.prototype._setRemove(View.prototype);
View.extend = BaseState.extend;
module.exports = View;
