text: const BaseModel = require('lib/BaseModel.js');
  const { sprintf } = require('sprintf-js');
  const BaseItem = require('lib/models/BaseItem.js');
  const ItemChange = require('lib/models/ItemChange.js');
  const Setting = require('lib/models/Setting.js');
  const { shim } = require('lib/shim.js');
  const { time } = require('lib/time-utils.js');
  const { _ } = require('lib/locale.js');
  const moment = require('moment');
  const lodash = require('lodash');
  
  class Note extends BaseItem {
  
  	static tableName() {
  		return 'notes';
  	}
  
  	static fieldToLabel(field) {
  		const fieldsToLabels = {
  			title: _('title'),
  			user_updated_time: _('updated date'),
  			user_created_time: _('created date'),
  		};
  
  		return field in fieldsToLabels ? fieldsToLabels[field] : field;
  	}
  
  	static async serialize(note, type = null, shownKeys = null) {
  		let fieldNames = this.fieldNames();
  		fieldNames.push('type_');
  		return super.serialize(note, 'note', fieldNames);
  	}
  
  	static async serializeForEdit(note) {
  		return super.serialize(note, 'note', ['title', 'body']);
  	}
  
  	static async unserializeForEdit(content) {
  		content += "\n\ntype_: " + BaseModel.TYPE_NOTE;
  		let output = await super.unserialize(content);
  		if (!output.title) output.title = '';
  		if (!output.body) output.body = '';
  		return output;
  	}
  
  	static async serializeAllProps(note) {
  		let fieldNames = this.fieldNames();
  		fieldNames.push('type_');
  		lodash.pull(fieldNames, 'title', 'body');
  		return super.serialize(note, 'note', fieldNames);
  	}
  
  	static minimalSerializeForDisplay(note) {
  		let n = Object.assign({}, note);
  
  		let fieldNames = this.fieldNames();
  
  		if (!n.is_conflict) lodash.pull(fieldNames, 'is_conflict');
  		if (!Number(n.latitude)) lodash.pull(fieldNames, 'latitude');
  		if (!Number(n.longitude)) lodash.pull(fieldNames, 'longitude');
  		if (!Number(n.altitude)) lodash.pull(fieldNames, 'altitude');
  		if (!n.author) lodash.pull(fieldNames, 'author');
  		if (!n.source_url) lodash.pull(fieldNames, 'source_url');
  		if (!n.is_todo) {
  			lodash.pull(fieldNames, 'is_todo');
  			lodash.pull(fieldNames, 'todo_due');
  			lodash.pull(fieldNames, 'todo_completed');
  		}
  		if (!n.application_data) lodash.pull(fieldNames, 'application_data');
  
  		lodash.pull(fieldNames, 'type_');
  		lodash.pull(fieldNames, 'title');
  		lodash.pull(fieldNames, 'body');
  		lodash.pull(fieldNames, 'created_time');
  		lodash.pull(fieldNames, 'updated_time');
  		lodash.pull(fieldNames, 'order');
  
  		return super.serialize(n, 'note', fieldNames);
  	}
  
  	static defaultTitle(note) {
  		if (note.body && note.body.length) {
  			const lines = note.body.trim().split("\n");
  			let output = lines[0].trim();
  			// Remove the first #, *, etc.
  			while (output.length) {
  				const c = output[0];
  				if (['#', ' ', "\n", "\t", '*', '`', '-'].indexOf(c) >= 0) {
  					output = output.substr(1);
  				} else {
  					break;
  				}
  			}
  			return output.substr(0, 80).trim();
  		}
  
  		return _('Untitled');
  	}
  
  	static geolocationUrl(note) {
  		if (!('latitude' in note) || !('longitude' in note)) throw new Error('Latitude or longitude is missing');
  		if (!Number(note.latitude) && !Number(note.longitude)) throw new Error(_('This note does not have geolocation information.'));
  		return this.geoLocationUrlFromLatLong(note.latitude, note.longitude);
  	}
  
  	static geoLocationUrlFromLatLong(lat, long) {
  		return sprintf('https://www.openstreetmap.org/?lat=%s&lon=%s&zoom=20', lat, long)
  	}
  
  	static modelType() {
  		return BaseModel.TYPE_NOTE;
  	}
  
  	static linkedItemIds(body) {
  		// For example: ![](:/fcca2938a96a22570e8eae2565bc6b0b)
  		if (!body || body.length <= 32) return [];
  		const matches = body.match(/\(:\/.{32}\)/g);
  		if (!matches) return [];
  		return matches.map((m) => m.substr(3, 32));
  	}
  
  	static async linkedItems(body) {
  		const itemIds = this.linkedItemIds(body);
  		const output = [];
  
  		for (let i = 0; i < itemIds.length; i++) {
  			const item = await BaseItem.loadItemById(itemIds[i]);
  			if (!item) continue;
  			output.push(item);
  		}
  
  		return output;
  	}
  
  	static async linkedItemIdsByType(type, body) {
  		const items = await this.linkedItems(body);
  		const output = [];
  
  		for (let i = 0; i < items.length; i++) {
  			const item = items[i];
  			if (item.type_ === type) output.push(item.id);
  		}
  
  		return output;
  	}
  
  	static async linkedResourceIds(body) {
  		return await this.linkedItemIdsByType(BaseModel.TYPE_RESOURCE, body);
  	}
  
  	static new(parentId = '') {
  		let output = super.new();
  		output.parent_id = parentId;
  		return output;
  	}
  
  	static newTodo(parentId = '') {
  		let output = this.new(parentId);
  		output.is_todo = true;
  		return output;
  	}
  
  	// Note: sort logic must be duplicated in previews();
  	static sortNotes(notes, orders, uncompletedTodosOnTop) {
  		const noteOnTop = (note) => {
  			return uncompletedTodosOnTop && note.is_todo && !note.todo_completed;
  		}
  
  		const noteFieldComp = (f1, f2) => {
  			if (f1 === f2) return 0;
  			return f1 < f2 ? -1 : +1;
  		}
  
  		// Makes the sort deterministic, so that if, for example, a and b have the
  		// same updated_time, they aren't swapped every time a list is refreshed.
  		const sortIdenticalNotes = (a, b) => {
  			let r = null;
  			r = noteFieldComp(a.user_updated_time, b.user_updated_time); if (r) return r;
  			r = noteFieldComp(a.user_created_time, b.user_created_time); if (r) return r;
  
  			const titleA = a.title ? a.title.toLowerCase() : '';
  			const titleB = b.title ? b.title.toLowerCase() : '';
  			r = noteFieldComp(titleA, titleB); if (r) return r;
  
  			return noteFieldComp(a.id, b.id);
  		}
  
  		return notes.sort((a, b) => {
  			if (noteOnTop(a) && !noteOnTop(b)) return -1;
  			if (!noteOnTop(a) && noteOnTop(b)) return +1;
  
  			let r = 0;
  
  			for (let i = 0; i < orders.length; i++) {
  				const order = orders[i];
  				if (a[order.by] < b[order.by]) r = +1;
  				if (a[order.by] > b[order.by]) r = -1;
  				if (order.dir == 'ASC') r = -r;
  				if (r !== 0) return r;
  			}
  
  			return sortIdenticalNotes(a, b);
  		});
  	}
  
  	static previewFields() {
  		return ['id', 'title', 'body', 'is_todo', 'todo_completed', 'parent_id', 'updated_time', 'user_updated_time', 'user_created_time', 'encryption_applied'];
  	}
  
  	static previewFieldsSql() {
  		return this.db().escapeFields(this.previewFields()).join(',');
  	}
  
  	static async loadFolderNoteByField(folderId, field, value) {
  		if (!folderId) throw new Error('folderId is undefined');
  
  		let options = {
  			conditions: ['`' + field + '` = ?'],
  			conditionsParams: [value],
  			fields: '*',
  		}
  
  		// TODO: add support for limits on .search()
  
  		let results = await this.previews(folderId, options);
  		return results.length ? results[0] : null;
  	}
  
  	static async previews(parentId, options = null) {
  		// Note: ordering logic must be duplicated in sortNotes(), which
  		// is used to sort already loaded notes.
  
  		if (!options) options = {};
  		if (!options.order) options.order = [
  			{ by: 'user_updated_time', dir: 'DESC' },
  			{ by: 'user_created_time', dir: 'DESC' },
  			{ by: 'title', dir: 'DESC' },
  			{ by: 'id', dir: 'DESC' },
  		];
  		if (!options.conditions) options.conditions = [];
  		if (!options.conditionsParams) options.conditionsParams = [];
  		if (!options.fields) options.fields = this.previewFields();
  		if (!options.uncompletedTodosOnTop) options.uncompletedTodosOnTop = false;
  		if (!('showCompletedTodos' in options)) options.showCompletedTodos = true;
  
  		if (parentId == BaseItem.getClass('Folder').conflictFolderId()) {
  			options.conditions.push('is_conflict = 1');
  		} else {
  			options.conditions.push('is_conflict = 0');
  			if (parentId) {
  				options.conditions.push('parent_id = ?');
  				options.conditionsParams.push(parentId);
  			}
  		}
  
  		if (options.anywherePattern) {
  			let pattern = options.anywherePattern.replace(/\*/g, '%');
  			options.conditions.push('(title LIKE ? OR body LIKE ?)');
  			options.conditionsParams.push(pattern);
  			options.conditionsParams.push(pattern);
  		}
  
  		let hasNotes = true;
  		let hasTodos = true;
  		if (options.itemTypes && options.itemTypes.length) {
  			if (options.itemTypes.indexOf('note') < 0) {
  				hasNotes = false;
  			} else if (options.itemTypes.indexOf('todo') < 0) {
  				hasTodos = false;
  			}
  		}
  
  		if (!options.showCompletedTodos) {
  			options.conditions.push('todo_completed <= 0');
  		}
  
  		if (options.uncompletedTodosOnTop && hasTodos) {
  			let cond = options.conditions.slice();
  			cond.push('is_todo = 1');
  			cond.push('(todo_completed <= 0 OR todo_completed IS NULL)');
  			let tempOptions = Object.assign({}, options);
  			tempOptions.conditions = cond;
  
  			let uncompletedTodos = await this.search(tempOptions);
  
  			cond = options.conditions.slice();
  			if (hasNotes && hasTodos) {
  				cond.push('(is_todo = 0 OR (is_todo = 1 AND todo_completed > 0))');
  			} else {
  				cond.push('(is_todo = 1 AND todo_completed > 0)');
  			}
  
  			tempOptions = Object.assign({}, options);
  			tempOptions.conditions = cond;
  			if ('limit' in tempOptions) tempOptions.limit -= uncompletedTodos.length;
  			let theRest = await this.search(tempOptions);
  
  			return uncompletedTodos.concat(theRest);
  		}
  
  		if (hasNotes && hasTodos) {
  
  		} else if (hasNotes) {
  			options.conditions.push('is_todo = 0');
  		} else if (hasTodos) {
  			options.conditions.push('is_todo = 1');
  		}
  
  		return this.search(options);
  	}
  
  	static preview(noteId) {
  		return this.modelSelectOne('SELECT ' + this.previewFieldsSql() + ' FROM notes WHERE is_conflict = 0 AND id = ?', [noteId]);
  	}
  
  	static conflictedNotes() {
  		return this.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 1');
  	}
  
  	static async conflictedCount() {
  		let r = await this.db().selectOne('SELECT count(*) as total FROM notes WHERE is_conflict = 1');
  		return r && r.total ? r.total : 0;
  	}
  
  	static unconflictedNotes() {
  		return this.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 0');
  	}
  
  	static async updateGeolocation(noteId) {
  		if (!Setting.value('trackLocation')) return;
  		if (!Note.updateGeolocationEnabled_) return;
  
  		let startWait = time.unixMs();
  		while (true) {
  			if (!this.geolocationUpdating_) break;
  			this.logger().info('Waiting for geolocation update...');
  			await time.sleep(1);
  			if (startWait + 1000 * 20 < time.unixMs()) {
  				this.logger().warn('Failed to update geolocation for: timeout: ' + noteId);
  				return;
  			}
  		}
  
  		let geoData = null;
  		if (this.geolocationCache_ && this.geolocationCache_.timestamp + 1000 * 60 * 10 > time.unixMs()) {
  			geoData = Object.assign({}, this.geolocationCache_);
  		} else {
  			this.geolocationUpdating_ = true;
  
  			this.logger().info('Fetching geolocation...');
  			try {
  				geoData = await shim.Geolocation.currentPosition();
  			} catch (error) {
  				this.logger().error('Could not get lat/long for note ' + noteId + ': ', error);
  				geoData = null;
  			}
  
  			this.geolocationUpdating_ = false;
  
  			if (!geoData) return;
  
  			this.logger().info('Got lat/long');
  			this.geolocationCache_ = geoData;
  		}
  
  		this.logger().info('Updating lat/long of note ' + noteId);
  
  		let note = await Note.load(noteId);
  		if (!note) return; // Race condition - note has been deleted in the meantime
  
  		note.longitude = geoData.coords.longitude;
  		note.latitude = geoData.coords.latitude;
  		note.altitude = geoData.coords.altitude;
  		return Note.save(note);
  	}
  
  	static filter(note) {
  		if (!note) return note;
  
  		let output = super.filter(note);
  		if ('longitude' in output) output.longitude = Number(!output.longitude ? 0 : output.longitude).toFixed(8);
  		if ('latitude' in output) output.latitude = Number(!output.latitude ? 0 : output.latitude).toFixed(8);
  		if ('altitude' in output) output.altitude = Number(!output.altitude ? 0 : output.altitude).toFixed(4);
  		return output;
  	}
  
  	static async copyToFolder(noteId, folderId) {
  		if (folderId == this.getClass('Folder').conflictFolderId()) throw new Error(_('Cannot copy note to "%s" notebook', this.getClass('Folder').conflictFolderIdTitle()));
  
  		return Note.duplicate(noteId, {
  			changes: {
  				parent_id: folderId,
  				is_conflict: 0, // Also reset the conflict flag in case we're moving the note out of the conflict folder
  			},
  		});
  	}
  
  	static async moveToFolder(noteId, folderId) {
  		if (folderId == this.getClass('Folder').conflictFolderId()) throw new Error(_('Cannot move note to "%s" notebook', this.getClass('Folder').conflictFolderIdTitle()));
  
  		// When moving a note to a different folder, the user timestamp is not updated.
  		// However updated_time is updated so that the note can be synced later on.
  
  		const modifiedNote = {
  			id: noteId,
  			parent_id: folderId,
  			is_conflict: 0,
  			updated_time: time.unixMs(),
  		};
  
  		return Note.save(modifiedNote, { autoTimestamp: false });
  	}
  
  	static toggleIsTodo(note) {
  		if (!('is_todo' in note)) throw new Error('Missing "is_todo" property');
  
  		let output = Object.assign({}, note);
  		output.is_todo = output.is_todo ? 0 : 1;
  		output.todo_due = 0;
  		output.todo_completed = 0;
  
  		return output;
  	}
  
  	static async duplicate(noteId, options = null) {
  		const changes = options && options.changes;
  		const uniqueTitle = options && options.uniqueTitle;
  
  		const originalNote = await Note.load(noteId);
  		if (!originalNote) throw new Error('Unknown note: ' + noteId);
  
  		let newNote = Object.assign({}, originalNote);
  		delete newNote.id;
  
  		for (let n in changes) {
  			if (!changes.hasOwnProperty(n)) continue;
  			newNote[n] = changes[n];
  		}
  
  		if (uniqueTitle) {
  			const title = await Note.findUniqueItemTitle(uniqueTitle);
  			newNote.title = title;
  		}
  
  		return this.save(newNote);
  	}
  
  	static async save(o, options = null) {
  		let isNew = this.isNew(o, options);
  		if (isNew && !o.source) o.source = Setting.value('appName');
  		if (isNew && !o.source_application) o.source_application = Setting.value('appId');
  
  		const note = await super.save(o, options);
  
  		ItemChange.add(BaseModel.TYPE_NOTE, note.id, isNew ? ItemChange.TYPE_CREATE : ItemChange.TYPE_UPDATE);
  
  		this.dispatch({
  			type: 'NOTE_UPDATE_ONE',
  			note: note,
  		});
  
  		if ('todo_due' in o || 'todo_completed' in o || 'is_todo' in o || 'is_conflict' in o) {
  			this.dispatch({
  				type: 'EVENT_NOTE_ALARM_FIELD_CHANGE',
  				id: note.id,
  			});
  		}
  
  		return note;
  	}
  
  	static async batchDelete(ids, options = null) {
  		const { reg } = require('lib/registry.js');
  		const StudentHelperUtils = require('lib/StudentHelperUtils.js');
  
  		// Calendar events are only supported if authenticated with Google
  		const isUsingCalendarEvents = await reg.syncTarget().isAuthenticated() && StudentHelperUtils.syncTargetNameIs("google");
  
  		// Obtains the Google API used to handle calendar events
  		let googleApi;
  		if (isUsingCalendarEvents) {
  			const sync = await reg.syncTarget().synchronizer();
  			const fileApi = sync.api();
  			const fileApiDriver = fileApi.driver();
  			googleApi = fileApiDriver.api();
  		}
  
  		// Checks if at least one note has a calendar event.
  		let hasNotesWithCalendarEvents = false;
  		if (isUsingCalendarEvents) {
  			for (let i = 0; i < ids.length; i++) {
  				const noteId = ids[i];
  				const note = await Note.load(noteId);
  				if (!note) continue;
  				if (!note.is_todo) continue; // Only to-dos have calendar events
  
  				const hasCalendarEvent = await Note.noteHasCalendarEvent(googleApi, noteId);
  				if (hasCalendarEvent) {
  					hasNotesWithCalendarEvents = true;
  					break;
  				}
  			}
  		}
  
  		// Asks the user if should also delete calendar events from notes
  		let shouldDeleteCalendarEvents = false;
  		if (isUsingCalendarEvents && hasNotesWithCalendarEvents) {
  			shouldDeleteCalendarEvents = await Note.askIfShouldDeleteCalendarEvents();
  		}
  
  		// Deletes the notes (and maybe their calendar events)
  		const result = await super.batchDelete(ids, options);
  		for (let i = 0; i < ids.length; i++) {
  			const noteId = ids[i];
  
  			// Deletes the calendar event
  			if (isUsingCalendarEvents && shouldDeleteCalendarEvents) {
  				await Note.deleteNoteCalendarEvent(googleApi, noteId);
  			}
  
  			// Deletes the actual note
  			ItemChange.add(BaseModel.TYPE_NOTE, noteId, ItemChange.TYPE_DELETE);
  			this.dispatch({
  				type: 'NOTE_DELETE',
  				id: noteId,
  			});
  		}
  		return result;
  	}
  
  	static async noteHasCalendarEvent(api, noteId) {
  		//const note = await Note.load(noteId); // Gets the note, if needed
  		// See if there is an event with the same note id
  		try {
  			const evt = await api.execJson('GET', 'https://www.googleapis.com/calendar/v3/calendars/primary/events/' + noteId, {});
  			// Check if event is cancelled - "status":"cancelled"
  			if (JSON.stringify(evt).includes("\"status\":\"cancelled\"")) {
  				// Event already deleted
  				return false;
  			} else {
  				// Event exists
  				return true;
  			}
  		} catch (error) {
  			// Event not found
  			return false;
  		}
  	}
  
  	static async deleteNoteCalendarEvent(api, noteId) {
  		//const note = await Note.load(noteId); // Gets the note, if needed
  		try {
  			const del = await api.execJson('DELETE', 'https://www.googleapis.com/calendar/v3/calendars/primary/events/' + noteId);
  		} catch (error) {
  			// Event probably already deleted, or something else...
  		}
  
  	}
  
  	static async askIfShouldDeleteCalendarEvents() {
  		// Ask if events in Calendar should be deleted alogside with the note
  		return confirm("Also delete the created events from your Google Calendar?");
  	}
  
  	static dueNotes() {
  		return this.modelSelectAll('SELECT id, title, body, is_todo, todo_due, todo_completed, is_conflict FROM notes WHERE is_conflict = 0 AND is_todo = 1 AND todo_completed = 0 AND todo_due > ?', [time.unixMs()]);
  	}
  
  	static needAlarm(note) {
  		return note.is_todo && !note.todo_completed && note.todo_due >= time.unixMs() && !note.is_conflict;
  	}
  
  	// Tells whether the conflict between the local and remote note can be ignored.
  	static mustHandleConflict(localNote, remoteNote) {
  		// That shouldn't happen so throw an exception
  		if (localNote.id !== remoteNote.id) throw new Error('Cannot handle conflict for two different notes');
  
  		// For encrypted notes the conflict must always be handled
  		if (localNote.encryption_cipher_text || remoteNote.encryption_cipher_text) return true;
  
  		// Otherwise only handle the conflict if there's a different on the title or body
  		if (localNote.title !== remoteNote.title) return true;
  		if (localNote.body !== remoteNote.body) return true;
  
  		return false;
  	}
  
  }
  
  Note.updateGeolocationEnabled_ = true;
  Note.geolocationUpdating_ = false;
  
  module.exports = Note;