'use strict';

/*@
Ox.URL <f> URL controller
    (options) -> <o> URL controller
    options <o> Options object
        findKeys <[o]> Find keys
            id <s> Find key id
            type <s> Value type (like "string" or "integer")
        getItem <f> Tests if a string matches an item
            (string, callback) -> <u> undefined
            string <s> The string to be tested
            callback <f> callback function
                id <s> Matching item id, or empty
        getSpan <f> Tests if a string matches a span
            (item, view, string, callback) -> <u> undefined
            item <s> The item id, or empty
            view <s> The view, or empty
            string <s> The string to be tested
            callback <f> Callback function
                id <s> Matching span id, or empty
                view <s> Matching view, or empty
        pages <[s]> List of pages
        sortKeys <o> Sort keys for list and item views for all types
            typeA <o> Sort keys for this type
                list <o> Sort keys for list views for this type
                    viewA <[o]> Sort keys for this view
                        id <s> Sort key id
                        operator <s> Default sort operator ("+" or "-")
                item <o> Sort keys for item views for this type
                    viewA <[o]> Sort keys for this view
                        id <s> Sort key id
                        operator <s> Default sort operator ("+" or "-")
        spanType <o> Span types for list and item views for all types
            typeA <o> Span types for this type
                list <o> Span types for list views for this type
                    viewA <s> Span type for this view
                        Can be "date", "duration" or "location"
                item <o> Span types for item views for this type
                    viewA <s> Span type for this view
                        Can be "date", "duration" or "location"
        types <[s]> List of types
        views <o> List and item views for all types
            typeA <o> Views for type "typeA"
                list <[s]> List views for this type
                item <[s]> Item views for this type
@*/

/*

example.com[/page]
or
example.com[/type][/item][/view][/span][/sort][/find]

page    Special page, like "about" or "contact"
type    Section a.k.a. item type, like "movies", "edits", "texts" etc.
item    Item id or title, like in '/movies/0060304', '/movies/inception' or
        'texts/ABC'. Testing this is asynchonous.
view    List or item view, like "clips" or "map". Both list and item views are
        per type.
span    Position or selection in a view, either one or two coordinates or one
        id, like in "video/01:00", "video/-01:00", "video/01:00,-01:00",
        "video/@annotationABC", "video/@subtitles:23", "text/@chapter42",
        "map/0,0", "map/-45,-90,45,90", "map/@barcelona", "image/100,100" etc.
        Testing id is asynchronous.
sort    Sort, like "title" or "-director" or "country,year,-language,+runtime"
find    Query, like a=x or a=x&b=y or a=x&(b=y|c=z). A query object has the form
        {conditions: [], operator: ''} (logical operator), and a condition
        object has the form {key: '', value: '' or ['', ''], operator: ''}
        (comparison operator) or {conditions: [], operator: ''} (logical
        operator). Condition strings can be more than just "k=v", see below.

String Key Value Operator
v      *   v     =        any text or string contains or any number is
!v     *   v     !=       no text or string contains and no number is
k=v    k   v     =        contains (text or string), is (number)
k!=v   k   v     !=       does not contain (text or string), is not (number)
k==v   k   v     ==       is (string)
k!==v  k   v     !==      is not (string)
k=v*   k   v     ^        starts with (string)
k!=v*  k   v     !^       does not start with (string)
k=*v   k   v     $        ends with (string)
k!=*v  k   v     !$       does not end with (string)
k<v    k   v     <        is less than (number)
k!<v   k   v     !<       is not less than (number)
k>v    k   v     >        is more than (number)
k!>v   k   v     !>       is not more than (number)
k=v,w  k   [v,w] =        is between (number), contains (string or text)
k!=v,w k   [v,w] !=       is not between (number), does not contain (string or text)

All parts of the URL can be omitted, as long as the order is preserved.

example.com/foo
    If "foo" is not a type, item (of the default type), list view (of the
    default type), span id (of any list view of the default type) or sort key
    (of any list view of the default type), then this means find *=foo
example.com/title, or example.com/+title or example.com/-title
    If this neither matches a type or default type item, then this will be sort
example.com/clip/+duration/title=foo
    If "clip" is a default type list view, this will show all clips of items
    that match title=foo, sorted by item duration in ascending order
example.com/clip/+clip.duration/subtitles=foo
    If "clip" is a default type list view and "subtitles" is an annotation type,
    this will show all clips that match subtitles=foo, sorted by clip duration
    in ascending order. (In pan.do/ra's clip view, annotation=foo is always per
    clip. There is no way to show all clips of all items where any clip matches
    subtitles=foo, this doesn't seem to be needed.)
example.com/map/@paris/duration/title!=london
    If "map" is a default type list view and "paris" is a place name, this will
    zoom the map to Paris, show all places of items that match title!=london,
    and when a place is selected sort matching clips by item duration in
    default order.
example.com/calendar/1900,2000/clip:duration/event=hiroshima
    If "calendar" is a default type list view, this will zoom the calendar to
    the 20th century, show all events of all items that match event=hiroshima,
    and when an event is selected sort matching clips by clip duration in
    default order. (In pan.do/ra's map and calendar view, annotation=foo is
    always per item. There is no way to show all events of all clips that match
    event=hiroshima, this doesn't seem to be needed.)

example.com/2001/2001 -> example.com/0062622/video/00:33:21
    2001 matches an item title (word match), the second 2001 is a valid duration
example.com/2002/2002 -> example.com/calendar/2002/2002
    2002 is a valid duration, but no list view supports durations. Then it is
    read as a year, and we get calendar view with find *=2002
example.com/@paris/london -> example.com/map/@paris/london
    paris matches place ABC (case-insensitive), but (assuming) find *=london
    does not match place ABC, "paris" becomes the map query
example.com/@paris/paris -> example.com/map/ABC/paris
    paris matches place ABC (case-insensitive), so we get map view, zoomed to
    ABC/Paris, which is selected, with find *=paris
example.com/@renaissance/renaissance -> example.com/calendar/ABC/renaissance
    renaissaince matches an event name (case-insensitive), so we get calendar
    view, zoomed to the Renaissance, with find *=renaissance
example.com/@foo/foo -> example.com/map/@foo/foo
    foo doesn't match a place or event name, but getSpan() sets the map query to
    foo and returns @foo, so we get map view, zoomed to Foo, with find *=foo
example.com/clip:duration -> example.com/clip/clip:duration
    clip:duration is not a sort key of the default view (grid), so the view is
    set to the first list view that accepts this sort key

*/

Ox.URL = function(options) {

    var self = {}, that = {};

    self.options = Ox.extend({
        // fixme: find keys are also per type/list|item/view
        // since one can search for layer properties in some item views
        findKeys: [],
        getItem: null,
        getSpan: null,
        pages: [],
        spanType: {},
        sortKeys: {},
        types: [],
        views: {}
    }, options);

    Ox.Log('Core', 'Ox.URL options', self.options)

    self.previousTitle = '';
    self.previousURL = '';

    window.onpopstate = function() {
        self.previousTitle = document.title;
        self.previousURL = document.location.pathname
            + document.location.search
            + document.location.hash;
    };

    function constructCondition(condition) {
        var key = condition.key == '*' ? '' : condition.key,
            operator = condition.operator,
            value;
        value = (
            Ox.isArray(condition.value) ? condition.value : [condition.value]
        ).map(function(value) {
            return encodeValue(constructValue(value, condition.key));
        }).join(',');
        if (!key) {
            operator = operator.replace('=', '');
        } else if (operator.indexOf('^') > -1) {
            operator = operator.replace('^', '=');
            value += '*';
        } else if (operator.indexOf('$') > -1) {
            operator = operator.replace('$', '=');
            value = '*' + value;
        }
        return [key, operator, value].join('');
    }

    function constructDate(date) {
        return Ox.formatDate(date, '%Y-%m-%d', true);
    }

    function constructDuration(duration) {
        return Ox.formatDuration(duration, 3).replace(/\.000$/, '');
    }

    function constructFind(find) {
        return find.conditions.map(function(condition) {
            return condition.conditions
                ? '(' + constructFind(condition) + ')'
                : constructCondition(condition);
        }).join(find.operator);
    }

    function constructLocation(location) {
        return location.join(',');
    }

    function constructSort(sort, state) {
        var sortKeys = self.options.sortKeys[state.type][
            !state.item ? 'list' : 'item'
        ][state.view];
        return sortKeys ? sort.map(function(sort) {
            return (
                Ox.getObjectById(sortKeys, sort.key).operator == sort.operator
                    ? '' : sort.operator
            ) + sort.key;
        }).join(',') : '';
    }

    function constructSpan(span, state) {
        var spanType = self.options.spanType[state.type][
                !state.item ? 'list' : 'item'
            ][state.view];
        return (Ox.isArray(span) ? span : [span]).map(function(point) {
            return Ox.isNumber(point) ? (
                spanType == 'date' ? constructDate(point)
                : spanType == 'duration' ? constructDuration(point)
                : constructLocation(point)
            ) : point;
        }).join(',');
    }

    function constructURL(state) {
        var parts = [];
        if (state.page) {
            parts.push(state.page);
        } else {
            if (self.options.types.indexOf(state.type) > 0) {
                parts.push(state.type);
            }
            if (state.item) {
                parts.push(state.item);
            }
            if (state.type && self.options.views[state.type][
                state.item ? 'item' : 'list'
            ].indexOf(state.view) > -1) {
                parts.push(state.view);
            }
            if (state.span && state.span.length) {
                parts.push(constructSpan(state.span, state));
            }
            if (state.sort && state.sort.length) {
                parts.push(constructSort(state.sort, state));
            }
            if (state.find) {
                parts.push(constructFind(state.find));
            }
        }
        return '/' + Ox.filter(parts).join('/');
    }

    function constructValue(str, key) {
        var findKey = Ox.getObjectById(self.options.findKeys, key),
            type = Ox.isArray(findKey.type) ? findKey.type[0] : findKey.type,
            value = str,
            values = findKey.values;
        return type == 'enum' ? values[value] : value;
    }

    function decodeValue(str) {
        return decodeURIComponent(str);
    }

    function encodeValue(str) {
        // var chars = '/&|()=*:';
        var chars = '&|()=*',
            ret = '';
        str.toString().split('').forEach(function(char) {
            var index = chars.indexOf(char);
            ret += index > -1
                ? '%' + char.charCodeAt(0).toString(16).toUpperCase()
                : char;
        });
        return ret;
    }

    function isNumericalSpan(str) {
        return str.split(',').every(function(str) {
            return /^[0-9-\.:]+$/.test(str);
        });
    }

    function getSpanType(str, types) {
        Ox.Log('Core', 'getSpanType', str, types)
        var canBeDate = types.indexOf('date') > -1,
            canBeDuration = types.indexOf('duration') > -1,
            canBeLocation = types.indexOf('location') > -1,
            length = str.split(',').length;
        return canBeDate && /\d-/.test(str) ? 'date'
            : canBeDuration && /:/.test(str) ? 'duration'
            : canBeLocation && length == 4 ? 'location'
            // leaves us with [-]D[.D][,[-]D[.D]]
            : canBeDuration ? 'duration'
            : canBeDate && !/\./.test(str) ? 'date'
            : canBeLocation && length == 2 ? 'location' : ':'
    }

    function parseCondition(str) {
        Ox.Log('Core', 'PARSE COND', str)
        var condition = {},
            operators = ['!==', '==', '!=', '=', '!<', '<', '!>', '>'],
            split;
        Ox.forEach(operators, function(operator) {
            if (str.indexOf(operator) > - 1) {
                split = str.split(operator);
                condition = {
                    key: split.shift(),
                    value: split.join(operator),
                    operator: operator
                };
                Ox.Break();
            }
        });
        if (
            !condition.operator
            || Ox.getIndexById(self.options.findKeys, condition.key) == -1
        ) {
            // missing operator or unknown key
            condition = {key: '*', value: str, operator: '='};
        }
        if (['=', '!='].indexOf(condition.operator) > -1) {
            if (condition.value[0] == '*') {
                condition.value = condition.value.slice(1);
                condition.operator = condition.operator.replace('=', '$')
            } else if (condition.value[condition.value.length - 1] == '*') {
                condition.value = condition.value.slice(0, -1);
                condition.operator = condition.operator.replace('=', '^')
            }
        }
        if (
            ['date', 'enum', 'float', 'integer', 'time', 'year'].indexOf(
                Ox.getObjectById(self.options.findKeys, condition.key).type
            ) > -1
            && condition.value.indexOf(',') > -1
        ) {
            condition.value = condition.value.split(',').map(function(value) {
                return parseValue(decodeValue(value), condition.key);
            });
        } else {
            condition.value = parseValue(decodeValue(condition.value), condition.key);
        }
        return condition;
    }

    function parseDate(str) {
        return Ox.formatDate(Ox.parseDate(str, true), '%Y-%m-%d');
    }

    function parseDuration(str) {
        return Ox.parseDuration(str);
    }

    function parseFind(str) {
        str = (str || '').replace(/%7C/g, '|');
        var conditions, counter = 0,
            find = {conditions: [], operator: '&'},
            subconditions = [];
        if (str.length) {
            // replace subconditions with placeholder,
            // so we can later split by main operator
            Ox.forEach(str, function(c, i) {
                if (c == ')') {
                    counter--;
                }
                if (counter >= 1) {
                    subconditions[subconditions.length - 1] += c;
                }
                if (c == '(') {
                    (++counter == 1) && subconditions.push('');
                }
            });
            subconditions = subconditions.filter(function(subcondition) {
                // make sure empty brackets don't throw errors
                return !!subcondition;
            });
            subconditions.forEach(function(subcondition, i) {
                str = str.replace(subcondition, i);
            });
            find.operator = str.indexOf('|') > -1 ? '|' : '&'
            find.conditions = str.split(find.operator).map(function(condition, i) {
                var ret;
                if (condition[0] == '(') {
                    // re-insert subcondition
                    ret = parseFind(subconditions[parseInt(condition.slice(1, -1))]);
                } else {
                    ret = parseCondition(condition);
                }
                return ret;
            });
        }
        return find;
    }

    function parseLocation(str) {
        return str.split(',').map(function(str, i) {
            return Ox.limit(parseInt(str, 10), -90 * (i + 1), 90 * (i + 1));
        });
    }

    function parseSort(str, state) {
        return str.split(',').map(function(str) {
            var hasOperator = /^[\+-]/.test(str);
            return {
                key: hasOperator ? str.slice(1) : str,
                operator: hasOperator
                    ? str[0]
                    : Ox.getObjectById(self.options.sortKeys[state.type][
                        !state.item ? 'list' : 'item'
                    ][state.view], str).operator
            };
        });
    }

    function parseSpan(str, type) {
        var split = str.split(',');
        if (split.length == 4) {
            split = [split[0] + ',' + split[1], split[2] + ',' + split[3]];
        }
        return split.map(
            type == 'date' ? parseDate
            : type == 'duration' ? parseDuration
            : parseLocation
        );
    }

    function parseURL(str, callback) {
        // fixme: removing trailing slash makes it impossible to search for '/'
        str = str.replace(/(^\/|\/$)/g, '');
        var parts = str.split('/'),
            state = {};
        if (parts[0] == '') {
            // empty URL
            callback(state);
        } else if (self.options.pages.indexOf(parts[0]) > -1) {
            // page
            state.page = parts[0];
            callback(state);
        } else {
            if (self.options.types.indexOf(parts[0]) > -1) {
                // type
                state.type = parts[0];
                parts.shift();
            } else {
                // set to default type
                state.type = self.options.types[0];
            }
            if (parts.length) {
                Ox.Log('Core', 'ST', state.type, self.options.views)
                if (self.options.views[state.type].list.indexOf(parts[0]) > -1) {
                    // list view
                    state.item = '';
                    state.view = parts[0];
                    parts.shift();
                    parseBeyondItem();
                } else {
                    // test for item id or name
                    self.options.getItem(parts[0].replace(/%20/g, ' '), function(item) {
                        state.item = item;
                        if (item) {
                            parts.shift();
                        }
                        parseBeyondItem();
                    });
                }
            } else {
                callback(state);
            }
        }
        function parseBeyondItem() {
            Ox.Log('Core', 'pBI', state, parts.join('/'));
            var span, spanType, spanTypes;
            if (
                parts.length && state.item
                && self.options.views[state.type].item.indexOf(parts[0]) > -1
            ) {
                // item view
                state.view = parts[0];
                parts.shift();
            }
            if (parts.length) {
                if (isNumericalSpan(parts[0])) {
                    // test for numerical span
                    spanTypes = self.options.spanType[state.type][
                        !state.item ? 'list' : 'item'
                    ];
                    // if no view is given then parse the span anyway,
                    // but make sure the span type could match a view
                    spanType = state.view
                        ? spanTypes[state.view]
                        : getSpanType(parts[0], Ox.unique(Ox.values(spanTypes)));
                    Ox.Log('Core', 'SPAN TYPE', spanType)
                    if (spanType) {
                        span = parseSpan(parts[0], spanType);
                        if (span) {
                            if (state.view) {
                                state.span = span;
                                parts.shift();
                            } else {
                                // if no view is given then switch to the first
                                // view that supports a span of this type
                                Ox.forEach(self.options.views[state.type][
                                    !state.item ? 'list' : 'item'
                                ], function(view) {
                                    if (spanTypes[view] == spanType) {
                                        state.view = view;
                                        state.span = span;
                                        parts.shift();
                                        Ox.Break();
                                    }
                                });
                            }
                        }
                    }
                }
                if (!state.span && /^[A-Z@]/.test(parts[0])) {
                    // test for span id or name
                    self.options.getSpan(state.item, state.view, decodeValue(parts[0]), function(span, view) {
                        Ox.Log('Core', 'span/view', span, view)
                        if (span) {
                            if (!state.view) {
                                // set list or item view
                                state.view = view;
                            }
                            state.span = span;
                            parts.shift();
                        }
                        parseBeyondSpan();
                    });
                } else {
                    parseBeyondSpan();
                }
            } else {
                if (!state.view) {
                    // set to default item view
                    state.view = self.options.views[state.type].item[0];
                }
                callback(state);
            }
        }
        function parseBeyondSpan() {
            Ox.Log('Core', 'pBS', state)
            var sortKeyIds, sortParts;
            if (parts.length) {
                sortParts = parts[0].split(',');
                sortKeyIds = Ox.map(self.options.sortKeys[state.type][
                    !state.item ? 'list' : 'item'
                ], function(sortKeys) {
                    return sortKeys.map(function(sortKey) {
                        return sortKey.id;
                    });
                });
                // test if sort keys match the given view,
                // or any view if no view is given
                Ox.forEach(
                    state.view ? [state.view]
                    : self.options.views[state.type][!state.item ? 'list' : 'item'],
                    function(view) {
                        if (sortKeyIds[view] && sortParts.every(function(part) {
                            return sortKeyIds[view].indexOf(part.replace(/^[\+-]/, '')) > -1;
                        })) {
                            if (!state.view) {
                                // set list or item view
                                state.view = view;
                            }
                            // sort
                            state.sort = parseSort(parts[0], state);
                            parts.shift();
                            Ox.Break();
                        }
                    }
                );
            }
            if (!state.view) {
                // set to default list or item view
                state.view = self.options.views[state.type][
                    !state.item ? 'list' : 'item'
                ][0];
            }
            if (parts.length) {
                // find
                state.find = parseFind(parts.join('/'));
            }
            callback(state);
        }
    }

    function parseValue(str, key) {
        var findKey = Ox.getObjectById(self.options.findKeys, key),
            type = Ox.isArray(findKey.type) ? findKey.type[0] : findKey.type,
            value = str,
            values = findKey.values;
        if (type == 'boolean') {
            value = ['', 'false'].indexOf(str) == -1;
        } else if (type == 'date') {
            value = Ox.formatDate(Ox.parseDate(str, true), '%F', true);
        } else if (type == 'enum') {
            value = Math.max(values.map(function(value) {
                return value.toLowerCase();
            }).indexOf(str.toLowerCase()), 0);
        } else if (type == 'float') {
            value = parseFloat(str) || 0;
        } else if (type == 'integer') {
            value = Math.round(str) || 0;
        } else if (type == 'time') {
            value = Ox.formatDurarion(Ox.parseDuration(value));
        } else if (type == 'year') {
            value = Math.round(str) || 1970;
        }
        return value.toString();
    }

    function saveURL() {

    }

    /*@
    parse <f> parse
        (callback) -> <o> parse state from document.location
        (url, callback) -> <o> parse state from passed url
    @*/
    that.parse = function() {
        var str = arguments.length == 2 ? arguments[0]
                : document.location.pathname
                + document.location.search
                + document.location.hash,
            callback = arguments[arguments.length - 1];
        parseURL(str, callback);
        return that;
    }

    /*@
    pop <f> Sets the URL to the previous URL
    @*/
    that.pop = function() {
        if (self.previousURL) {
            history.pushState && history.pushState(
                {}, self.previousTitle, self.previousURL
            );
            document.title = self.previousTitle;
        }
        return !!self.previousURL;
    };

    /*@
    push <f> Pushes a new URL
        (state, title, url, callback) -> <o> URL controller
        state <o> State for the new URL
            If state is null, it will be derived from url
        title <s> Title for the new URL
        url <s|o> New URL
            If url is null, it will be derived from state
        callback <f> callback function
            state <o> New state
    @*/
    that.push = function(state, title, url, callback) {
        if (!state) {
            parseURL(url, function(state) {
                pushState(state, title, url);
            });
        } else {
            url = url || constructURL(state);
            pushState(state, title, url);
        }
        function pushState(state, title, url) {
            self.previousTitle = document.title;
            self.previousURL = document.location.pathname
                + document.location.search
                + document.location.hash;
            if (url != self.previousURL) {
                history.pushState && history.pushState(
                    Ox.extend(state, {title: title}), '', url
                );
                document.title = title;
                callback && callback(state);
            }
        }
    }

    /*@
    replace <f> Replaces the URL with a new URL
        (state, title, url, callback) -> <o> URL controller
        state <o> State for the new URL
            If state is null, it will be derived from url
        title <s> Title for the new URL
        url <s|o> New URL
            If url is null, it will be derived from state
        callback <f> callback function
            state <o> New state
    @*/
    that.replace = function(state, title, url, callback) {
        if (!state) {
            parseURL(url, function(state) {
                replaceState(state, title, url);
            });
        } else {
            url = url || constructURL(state);
            replaceState(state, title, url);
        }
        function replaceState(state, title, url) {
            history.replaceState && history.replaceState(
                Ox.extend(state, {title: title}), '', url
            );
            document.title = title;
            callback && callback(state);
        }
    }

    return that;

};
