/*!
 * This source file is part of the open source project
 * ExpressionEngine (https://expressionengine.com)
 *
 * @link      https://expressionengine.com/
 * @copyright Copyright (c) 2003-2022, Packet Tide, LLC (https://www.packettide.com)
 * @license   https://expressionengine.com/license Licensed under Apache License, Version 2.0
 */

(function($) {

"use strict";


		// @todo @pk ideas -------------

		// ensure copyright notice on all files (I always forget ...)

		// edit specific:

		// add base64 thing to "add" tab link to "save" the search
		//		- need global method to manipulate it first
		// clear search link (redirect to base path)
		// add sort to "return to filtered entries"? (feature)


		// TODO:

		// sorting on single page, should just need to set up with add_data
		// make sure that all of this works with multiple tables on the page (it should)
		// make keyup timeout configurable
		// flip headerSortUp and down in the css, is silly

		// /@todo @pk todo/ideas -------------

$.widget('ee.table', {

	_listening: $(),		// form elements the filter is listening to
	_template_string: '',	// raw template
	_current_data: {},		// current_data

	options: {
		uniqid: null,		// uniqid of related elements
		base_url: null,		// url for requests

		pagination: null,	// element(s)

		template: null,		// table template
		pag_template: null,	// pagination template

		rows: [],			// initial row data
		sort: [],			// [[column, desc], [column2, asc]]
		columns: [],		// column names to match_data / search, filter on the spot

		cache_limit: 600,	// number of items, not pages!

		filters: {},		// map of active filters {"field": "bar"}, usually updated right before request is made

		cssAsc: 'headerSortDown',	// matches tablesorter.js
		cssDesc: 'headerSortUp'
	},

	// jQuery ui widget constructor
	_create: function() {

		var self = this,
			options = self.options;

		// cache content parent and no results
		self.tbody = self.element.find('tbody');

		if ( ! self.tbody.length) {
			self.tbody = $('<tbody/>');
			self.element.append(self.tbody);
		}

		self.no_results = $('<div />').html(options.no_results);

		// check if we need no results to begin with
		if ( ! self.tbody.children().length) {
			self.element.hide();
			self.element.after(self.no_results);
		}

		// set defaults
		self.filters = options.filters;

		// fix ampersands
		options.base_url = options.base_url.replace(new RegExp('&amp;', 'g'), '&');

		// setup dependencies
		self.sort = new Sort(options, self);
		self.cache = new Cache(options.cache_limit);
		self.pagination = new Pagination(options, self);

		// cache initial data
		var cache_id = self._prep_for_cache(),
			cache_data = {
				'html_rows': self.tbody.find('tr'),
				'pagination': self.pagination.html(),
				'rows': options.rows
			};

		self.cache.set(cache_id, cache_data);
		self._current_data = cache_data;

		// create unique template name and compile
		self.template_id = options.uniqid + '_row_template';
		self.set_template(options.template);

		// bind create event (@todo consider ditching, pretty much impossible to bind on)
		self._trigger('create', null, self._ui( { data: cache_data } ));
	},

	/**
	 * Get container (tbody)
	 */
	get_container: function() {
		return this.tbody;
	},

	/**
	 * Set container
	 */
	set_container: function(el) {
		this.tbody = $(el);
	},

	/**
	 * Get header element by short name
	 */
	get_header: function(name) {
		self.element.find('th').filter(function() {
			return ($(this).data('table_column') == name);
		});
	},

	/**
	 * Get raw template string
	 */
	get_template: function() {
		return this._template_string;
	},

	/**
	 * Use a different template
	 */
	set_template: function(template) {
		this._template_string = template;
		$.template(this.template_id, template);
	},

	/**
	 * Get current cache
	 */
	get_current_data: function() {
		return this._current_data;
	},

	/**
	 * Clear all caches
	 */
	clear_cache: function() {
		this.cache.clear();
		return this;
	},

	/**
	 * Unbind all filters
	 */
	clear_filters: function() {
		// @todo reset form content?

		this.filters = {};
		this._listening.each(function() {
			$(this).unbind('interact.ee_table');
		});
		return this;
	},

	/**
	 * Reset sort to initial conditions
	 */
	clear_sort: function() {
		// @todo fire sort events
		this.sort.reset();
		return this;
	},

	/**
	 * Add a filter
	 *
	 * Can be a form or a regular object
	 */
	add_filter: function(obj) {
		var self = this,
			url = EE.BASE + '&' + self.options.base_url;

		// add to filters and update right away
		// @todo do not hardcode url!
		if ($.isPlainObject(obj)) {
			self._set_filter(self._listening);
			self.filters = $.extend(self.filters, obj);
			self._request(url);
			return this;
		}

		var form = obj.closest('form'),
			evts = 'interact.ee_table',
			_timeout;

		if (form && obj.is(form)) {
			// bind to submit only if it's a form
			evts += ' submit.ee_table';
		} else {
			// A filter outside of a form? We most likely don't want enter to
			// do anything. This was happening in the file modal search box
			obj.bind('keydown', function(e) {
				if (e.keyCode == 13) {
					e.preventDefault();
				}
			});
		}

		$(obj).bind(evts, function(e) {

			// @todo only timeout on some inputs? (textareas)

			clearTimeout(_timeout);
			_timeout = setTimeout(function() {
				self._set_filter(self._listening);
				self._request(url);
			}, 200);

			return false;
		});

		self._listening = self._listening.add(obj);
		self._set_filter(self._listening);

		return this;
	},


	/**
	 * Set sort (see sort::set for info)
	 */
	set_sort: function(column, dir) {
		this.sort.set(column, dir);
		return this;
	},

	/**
	 * Add sort (see sort::add for info)
	 */
	add_sort: function(column, dir) {
		this.sort.add(column, dir);
		return this;
	},

	/**
	 * Refresh with current filters and sort intact
	 */
	refresh: function() {
		var url = EE.BASE + '&' + this.options.base_url;

		this._request(url);
		return this;
	},

	/**
	 * Make a request with the current filters and sort
	 *
	 * Updates the main table, pagination, caches, and triggers
	 * the load and update events.
	 */
	_request: function(url) {
		var self = this,
			data, success;

		self._trigger('load', null, self._ui(/* @todo args */));

		// A cache hit and an ajax result below are both
		// considered successes and will call this with
		// the correct data =)
		success = function(res) {
			self._current_data = res;

			// @todo only remove those that are not in the result set?
			if ( ! res.rows.length) {
				if (self.tbody.is('tbody')) {
					self.tbody.empty();
					self.element.hide();
					self.element.after(self.no_results);
				} else {
					self.tbody.html(self.no_results);
				}
			} else {
				self.element.show();
				self.tbody.html(res.html_rows);
				self.no_results.remove();
			}

			self.pagination.update(res.pagination);
			self._trigger('update', null, self._ui( {data: res} ));
		};

		var cache_id = self._prep_for_cache();

		// Do we have this page cached?
		data = self.cache.get(cache_id);
		if (data !== null) {
			return success(data);
		}

		// The pagination library reads from get, so we need
		// to move tbl_offset. Doing it down here allows it
		// to be in the cache key without dark magic.
		if (self.filters.tbl_offset) {
			url += '&tbl_offset=' + self.filters.tbl_offset;
			delete self.filters.tbl_offset;
		}

		// Always send an XID
		self.filters.XID = EE.XID;

		// fire request start event (show progress indicator)
		$.ajax(url, {
			type: 'post',
			data: self.filters,
			success: function(data) {

				// parse data
				data.html_rows = $.tmpl(self.template_id, data.rows);
				data.pagination = self.pagination.parse(data.pagination);

				// add to cache
				self.cache.set(cache_id, data, data.rows.length);
				success(data);
			},
			dataType: 'json'
		});
	},

	/**
	 * Weed out the stuff we don't want in there, like XIDs,
	 * session ids, and blank values

	 * Also take this opportunity to create a stable cache key, as
	 * some browsers sort objects and some do not =( . To get consistency
	 * for those that don't sort, we push keys and values into an array,
	 * sort the array, and concat to get a string. -pk
	*/
	_prep_for_cache: function() {
		this.filters.tbl_sort = this.sort.get();

		var key, regex = /^(XID|S|D|C|M)$/,
			cache_key_relevant = [];

		for (key in this.filters) {
			if (this.filters[key] == '' || regex.exec(key) !== null) {
				delete this.filters[key];
			} else {
				cache_key_relevant.push(key, this.filters[key]);
			}
		}

		cache_key_relevant.sort();

		return cache_key_relevant.join(''); // debug $.param(this.filters);
	},

	/**
	 * Helper method to set the filter object
	 * from form elements.
	 */
	_set_filter: function(obj) {
		var els = obj.serializeArray(),
			self = this;

		$.each(els, function() {
			self.filters[this.name] = this.value;
		});
	},

	/**
	 * Event data helper
	 *
	 * Should reflect the state most hooks might care about
	 */
	_ui: function(add) {
		add = add || {};

		return $.extend({
			sort: this.sort.get(),// sort order [[column, asc/desc], [column2, asc/desc]]
			filters: this.filters // all applied filters
		}, add);
	}

});


// --------------------------------------------------------------------------


/**
 * Implements a LRU (least-recently-used) cache.
 */
function Cache(limit) {
	this.size = 0;
	this.limit = limit;
	this.cache = [];	 // [[page, data], [page2, data2]]
	this.cache_map = []; // [page, page, page] for faster access
}

Cache.prototype = {

	/*
	 * Get the cache limit
	 */
	limit: function() {
		return this.limit;
	},

	/*
	 * Get current cache size
	 */
	size: function() {
		return this.cache.length;
	},

	/**
	 * Add a cache item
	 *
	 * @param string	unique identifier
	 * @param mixed		data to cache
	 * @param int		penalty against cache limit [default = 1]
	 *
	 * We cache per page, but since our page length is variable, we want
	 * to control cache size per row. Cache_weight exists so that this
	 * plugin remains decoupled.
	 */
	set: function(id, data, cache_weight) {
		var penalty = cache_weight || 1;

		// evict data until this item fits
		while (this.size + penalty > this.limit) {
			var evicted = this.cache.shift();
			this.cache_map.shift();

			this.size -= evicted[2];
		}

		this.cache.push( [id, data, penalty] );
		this.cache_map.push(id);
		this.size += penalty;

		return this;
	},

	/**
	 * Get a cached item
	 *
	 * If the cache key exists, it is moved to the top
	 * of a stack to avoid eviction (LRU behavior).
	 *
	 * @param	string	cache id
	 * @return	mixed	cached item or null
	 */
	get: function(id) {
		var el, loc = this._find(id);

		if (loc > -1) {
			// detach and push on top of the queue (newest element)
			el = this.cache.splice(loc, 1)[0];
			this.cache.push(el);

			// fix up our map
			this.cache_map.splice(loc, 1);
			this.cache_map.push(el[0]);

			return el[1];
		}

		return null;
	},

	/**
	 * Delete a cached item
	 */
	'delete': function(id) {
		var el, loc = this._find(id);

		if (loc > -1) {
			el = this.cache.splice(loc, 1);
			this.cache_map.splice(loc, 1);
			this.size -= el[2];
		}

		return this;
	},

	/**
	 * Clear cache
	 */
	clear: function() {
		this.size = 0;
		this.cache = [];
		this.cache_map = [];

		return this;
	},

	/**
	 * Find item in cache
	 *
	 * Helper method as IE does not support indexOf
	 * on arrays. This is also the reason why cache_map
	 * exists: we can search it with a native function
	 * and it's faster to iterate if we fall back.
	 */
	_find: function(id) {
		// oh hello there IE
		if ( ! Array.prototype.indexOf) {
			var tmp = this.cache_map,
				len = tmp.length,
				i = 0;

			for (; i < len; i++) {
				if (tmp[i] == id) {
					return i;
				}
			}
			return -1;
		}

		// native functions!
		return this.cache_map.indexOf(id);
	}
};



/**
 * Table pagination class
 */
function Pagination(options, plugin) {
	var self = this;

	this.els = $('p.' + options.uniqid);
	this.template_id = options.uniqid + '_pag_template';

	// compile the template
	$.template(this.template_id, options.pagination);


	// _request will grab the new page, and then call update
	this.els.delegate('a', 'click', function() {
		var filters = self._extract_qs(this.href, plugin.options.base_url);

		plugin.add_filter(filters);
		return false;
	});
}

Pagination.prototype = {

	/**
	 * Parse the pagination data
	 *
	 * Only parsed once and then stuck into the
	 * page cache along with its data
	 */
	parse: function(data) {
		if ( ! data) {
			return '';
		}

		return $.tmpl(this.template_id, data).html();
	},

	/**
	 * Update the pagination html
	 *
	 * @param mixed results from parse [cached]
	 */
	update: function(data) {
		if ( ! data) {
			this.els.html('');
			return;
		}

		this.els.html(data).show();
	},

	/**
	 * Get the pagination html
	 *
	 * Used to fill the initial cache
	 */
	html: function() {
		return this.els.html();
	},

	// Private methods //

	/**
	 * Extract Query String from link
	 *
	 * Needed to allow pagination on "saved" searches,
	 * where the keywords might be in the url and we need
	 * to manually apply them to the next page.
	 */
	_qs_splitter: new RegExp('([^&=]+)=?([^&]*)', 'g'),
	_extract_qs: function(url, base) {
		url = url.replace(base, '');

		var seg,
			idx = url.indexOf('?'),
			res = {};

		// only work through the qs
		if (idx > 0) {
			url = url.slice(idx + 1);
		}

		while ( (seg = this._qs_splitter.exec(url)) ) {
			res[ decodeURIComponent(seg[1]) ] = decodeURIComponent(seg[2]);
		}

		return res;
	}
};


/**
 * Table sorting class
 */
function Sort(options, plugin) {
	var self = this;

	this.sort = [];
	this.plugin = plugin;
	this.headers = plugin.element.find('th');
	this.css = {
		'asc': options.cssAsc,
		'desc': options.cssDesc
	};

	// helpers
	this.header_map = {};
	this._initial_sort = options.sort;


	// @todo pass css and sort prefs configs (initial + allowed columns)
	// @todo make our own based on the php example sort?
	if ( ! options.pagination) {
		$(plugin.element).tablesorter();
		return;
	}

	// cache all headers and check if we want
	// them to be sortable

	this.headers.each(function() {
		var el = $(this),
			short_name = el.data('table_column');
		self.header_map[ short_name ] = el;

		el.data('sortable', options.columns[short_name].sort);
	});

	// setup events

	plugin.element.find('thead')
		.delegate('th', 'selectstart', function() { return false; }) // don't select with shift
		.delegate('th', 'click', function(e) {
			var el = $(this);

			// allow things like checkboxes inside table headers
			if (el.has('input').length) {
				return true;
			}

			// if holding shift key: add
			if ( ! el.data('sortable')) {
				return false;
			}

			var fn = e.shiftKey ? 'add' : 'set';
			self[fn](
				el.data('table_column'),
				el.hasClass(options.cssAsc) ? 'desc' : 'asc'
			);

			return false;
	});

	// setup initial sort without making a request
	// @todo, this could be better
	var l = this._initial_sort.length;

	while (l--) {

		var this_header_map = this.header_map[ this._initial_sort[l][0] ];

		if (this_header_map !== undefined)
		{
			this.sort.push(this._initial_sort[l]);
			this_header_map
				.toggleClass(this.css.asc, (this._initial_sort[l][1] === 'asc'))
				.toggleClass(this.css.desc, (this._initial_sort[l][1] === 'desc'));
		};
	}
}

Sort.prototype = {

	/**
	 * Get current sort
	 *
	 * @param	string	column name for sort to return [optional]
	 * @return	mixed	full sort array | sort direction of column | null
	 */
	get: function(column) {
		if (column) {
			var l = this.sort.length;

			while (l--) {
				if (this.sort[l][0] == column) {
					return this.sort[l][1];
				}
			}

			return null;
		}

		return this.sort;
	},

	/**
	 * Add sort to column
	 *
	 * @param	string	column name (or full sort array, see set)
	 * @param	string	sort direction [asc|desc]
	 */
	add: function(column, dir) {
		var sort = column, l;

		if (dir) {
			sort = [[column, dir]];
		}

		// @todo fire addSort events

		l = sort.length;
		while (l--) {
			this.sort.push(sort[l]);
			this.header_map[ sort[l][0] ]
				.toggleClass(this.css.asc, (sort[l][1] === 'asc'))
				.toggleClass(this.css.desc, (sort[l][1] === 'desc'));

			// @todo event
			//this._trigger('sort', null, this._ui(/* @todo args sort[l] */));
		}

		this.plugin.refresh();
		return this;
	},

	/**
	 * Set sort
	 *
	 * @param	mixed	sort array ([[field, dir], [field2, dir]])
	 */
	set: function(column, dir) {

		// clear and add
		this.clear();
		this.add(column, dir);

		return this;
	},

	/**
	 * Reset sort to initial conditions
	 */
	reset: function() {
		this.clear();
		this.set(this._initial_sort);

		this.plugin.refresh();
		return this;
	},

	/**
	 * Clear sort entirely, does not reset
	 */
	clear: function() {
		var l = this.sort.length;

		while (l--) {
			this.header_map[ this.sort[l][0] ].removeClass(
				this.css.asc + ' ' + this.css.desc
			);
			// @todo event
			// this._trigger('nosort', null, this._ui(/* @todo args this.sort[l] */));
		}

		this.sort = [];
		return this;
	}
}

})(jQuery);
