public class FileAndDirectoryLocations {
	
	public static final String BASE_DIR_STRING = System.getProperty("user.home") + File.separator + ".songdatabase";
	public static final String SONGS_SUBDIR_STRING = "songs";
	public static final String BACKUP_SUBDIR_STRING = "backup";
	public static final String SONGS_FILE_STRING = "songs.xml";
	public static final String SONGS_BACKUP_FILE_STRING = "songs-%s.xml";
	public static final String SETTINGS_SUBDIR_STRING = "settings";
	public static final String SETTINGS_FILE_STRING = "settings.xml";
	public static final String SETTINGS_FALLBACK_FILE_STRING = "settings-fallback.xml";
	public static final String STATISTICS_SUBDIR_STRING = "statistics";
	public static final String STATISTICS_FILE_STRING = "statistics.xml";
	public static final String LOG_SUBDIR_STRING = "log";
	public static final String DB_SUBDIR_STRING = "db";
	public static final String DB_FILE_STRING = "db.xml";
	public static final String DB_PROPERTIES_FILE_STRING = "db.properties.xml";
	
	public static String getDefaultSongsFileName() {
		return getSongsDir() + File.separator + SONGS_FILE_STRING;
	}
	
	/**
	 * If the parameter is not blank, return it - else return the default location for the songs file.
	 */
	public static String getSongsFileName(String fileName) {
		if (StringUtils.isBlank(fileName)) {
			return FileAndDirectoryLocations.getDefaultSongsFileName();
		} else {
			return fileName;
		}
	}
	
	public static String getDefaultDBFileName() {
		return getDBDir() + File.separator + DB_FILE_STRING;
	}
	
	public static String getDefaultDBPropertiesFileName() {
		return getDBDir() + File.separator + DB_PROPERTIES_FILE_STRING;
	}
	
	public static String getSettingsFileName() {
		return getSettingsDir() + File.separator + SETTINGS_FILE_STRING;
	}
	
	public static String getSettingsFallbackFileName() {
		return getSettingsDir() + File.separator + SETTINGS_FALLBACK_FILE_STRING;
	}
	
	public static String getStatisticsFileName() {
		return getStatisticsDir() + File.separator + STATISTICS_FILE_STRING;
	}
	
	private static String getDBDir() {
		return getDir(DB_SUBDIR_STRING);
	}
	
	private static String getSongsDir() {
		return getDir(SONGS_SUBDIR_STRING);
	}
	
	public static String getSongsBackupDir() {
		return getDir(SONGS_SUBDIR_STRING + File.separator + BACKUP_SUBDIR_STRING);
	}
	
	public static String getSongsBackupFile() {
		return getDir(SONGS_SUBDIR_STRING + File.separator + BACKUP_SUBDIR_STRING)
			+ File.separator + String.format(SONGS_BACKUP_FILE_STRING, DateTools.timestamp());
	}
	
	private static String getSettingsDir() {
		return getDir(SETTINGS_SUBDIR_STRING);
	}
	
	private static String getStatisticsDir() {
		return getDir(STATISTICS_SUBDIR_STRING);
	}
	
	/**
	 * This method is only for telling the user where the log file resides! Configuration of log writing is subject to
	 * the log framework's config files!
	 */
	public static String getLogDir() {
		return getDir(LOG_SUBDIR_STRING);
	}
	
	private static String getDir(String subDirectory) {
		String path = BASE_DIR_STRING + File.separator + subDirectory;
		File dir = new File(path);
		if (!dir.exists()) {
			dir.mkdirs();
		}
		return path;
	}
} public class MainController implements Scroller {
	
	private static final String PRESENTATION_DISPLAY_WARNING = """
		Could not start presentation!
		
		Please specify at least one existing presentation display:
		Check your system configuration
		and/or adjust this program's configuration
		(see tab "Global Settings")!""";
	
	private static final Logger LOG = LoggerFactory.getLogger(MainController.class);
	
	private Executor contentChanger = Executors.newSingleThreadExecutor();
	
	private MainWindow mainWindow;
	
	private final IOController ioController;
	private final StatisticsController statisticsController;
	private RemoteController remoteController;
	
	private String songsFileName = FileAndDirectoryLocations.getDefaultSongsFileName();
	private final SongsModel songs = new SongsModel(true);
	private SongsModelController songsController = new SongsModelController(songs);
	private SettingsModel settings = null;
	
	private List<SelectableDisplay> screens;
	private PresenterBundle presentationControl;
	private Song currentlyPresentedSong = null;
	
	private ExecutorService executor = Executors.newCachedThreadPool();
	private Future<?> countDownFuture;
	private Iterator<File> slideShowImages;
	private Future<?> slideShowFuture;
	private final static Pattern imagePattern = Pattern.compile("(?i)^.*\\.(png|jpg|jpeg|gif|bmp)$");
	
	private Thread shutdownHook = null;
	
	public MainController(IOController ioController, StatisticsController statisticsController) {
		this.ioController = ioController;
		this.statisticsController = statisticsController;
	}
	
	/**
	 * Will initialize a remote-controller instance or create a new one, if it is already initialized.
	 * It will also replace the songsController, if a database service is online.
	 * Run this function in a separate Thread to not block the UI and to see status changes.
	 */
	public void initRemoteController() {
		assert settings != null : "Settings must be load before calling initRemoteController";
		
		closeRemoteController();
		
		if (settings.get(SettingKey.REMOTE_ENABLED, Boolean.class)) {
			setRemoteStatus(RemoteStatus.CONNECTING);
			try {
				remoteController = new RemoteController(settings, this, mainWindow);
				remoteController.getHealthDB().onChange((h, args) -> {
					switch (h) {
						case online:
							if (!(songsController instanceof PatchController))
								songsController = new PatchController(songs, remoteController);
							break;
						case offline:
							if (!(songsController instanceof PatchController))
								break;
							SongsModelController oldController = songsController;
							songsController = new SongsModelController(songs);
							oldController.save();
							oldController.close();
							break;
					}
					setRemoteStatus(h == Health.online ? RemoteStatus.CONNECTED : RemoteStatus.DB_DISCONNECTED);
				});
				if (remoteController.getHealthDB().get() == Health.online && !(songsController instanceof PatchController))
					songsController = new PatchController(songs, remoteController);
				
				setRemoteStatus(RemoteStatus.CONNECTED);
			} catch (MqttException e) {
				setRemoteStatus(RemoteStatus.FAILURE);
				ErrorDialog.openDialog(null, """
					Error while connecting to remote server.
					Please check settings or your network connection.
					Otherwise ask your system or network administrator.
					To reconnect, type Strg+R.""");
			}
		}
	}
	
	/**
	 * Called if settings changed out of mainwindow.
	 * Will recreate a remote-controller instance if remote settings changed.
	 */
	public void settingsChanged() {
		assert settings != null : "Settings must be load before calling settingsChanged";
		
		boolean enableChanged = settings.get(SettingKey.REMOTE_ENABLED, Boolean.class) != (remoteController != null);
		if (enableChanged || (remoteController != null && remoteController.checkSettingsChanged(settings)))
			new Thread(() -> initRemoteController()).start();
	}
	
	/** called from constructor of {@link MainWindow} */
	public void setMainWindow(MainWindow mainWindow) {
		this.mainWindow = mainWindow;
	}
	
	public void setRemoteStatus(RemoteStatus status) {
		if (mainWindow != null)
			mainWindow.setRemoteStatus(status);
	}
	
	public void contentChange(Runnable command) {
		contentChanger.execute(command);
	}
	
	public boolean present(Presentable presentable) {
		SelectableDisplay screen1 = ScreenHelper.getScreen(screens, settings.get(SettingKey.SCREEN_1_DISPLAY, Integer.class));
		SelectableDisplay screen2 = ScreenHelper.getScreen(screens, settings.get(SettingKey.SCREEN_2_DISPLAY, Integer.class));
		
		int presentersConfigured = (settings.get(SettingKey.SCREEN_1_DISPLAY, Integer.class) != null ? 1 : 0)
			+ (settings.get(SettingKey.SCREEN_2_DISPLAY, Integer.class) != null ? 1 : 0)
			+ (remoteController != null ? 1 : 0);
		if (presentationControl != null
			&& presentationControl.getPresenters().size() == presentersConfigured
			&& (presentationControl.getPresenters().isEmpty() ||
				(presentationControl.getPresenters().get(0) instanceof PresenterWindow pw
					&& pw.metadataMatches(screen1, VirtualScreen.SCREEN_A)))
			&& (presentationControl.getPresenters().size() <= 2 ||
				(presentationControl.getPresenters().get(1) instanceof PresenterWindow pw
					&& pw.metadataMatches(screen2, VirtualScreen.SCREEN_B)))) {
			LOG.trace("re-using the existing presenters");
			currentlyPresentedSong = presentable.getSong();
			presentationControl.setContent(presentable);
			return true;
		} else {
			LOG.trace("using newly created presenters");
			return presentInNewPresenters(presentable, screen1, screen2);
		}
	}
	
	private boolean presentInNewPresenters(Presentable presentable, SelectableDisplay screen1, SelectableDisplay screen2) {
		PresenterBundle oldPresentationControl = presentationControl;
		presentationControl = new PresenterBundle();
		
		Presenter presenter1 = createPresenter(screen1, presentable, VirtualScreen.SCREEN_A);
		if (presenter1 != null) {
			presentationControl.addPresenter(presenter1);
		}
		
		Presenter presenter2 = createPresenter(screen2, presentable, VirtualScreen.SCREEN_B);
		if (presenter2 != null) {
			presentationControl.addPresenter(presenter2);
		}
		
		if (presentationControl.isEmpty()) {
			ErrorDialog
				.openDialog(
					null,
					PRESENTATION_DISPLAY_WARNING);
			return false;
		} else {
			if (remoteController != null)
				presentationControl.addPresenter(remoteController.getRemotePresenter(presentable));
			
			currentlyPresentedSong = presentable.getSong();
			
			if (currentlyPresentedSong != null) {
				startCountDown(settings.get(SettingKey.SECONDS_UNTIL_COUNTED, Integer.class), currentlyPresentedSong);
			} else {
				stopCountDown();
			}
			
			SwingUtilities.invokeLater(() -> {
				// start presentation
				try {
					presentationControl.showPresenter();
				} catch (IllegalStateException ise) {
					LOG.warn("could not show presenter window", ise);
					ErrorDialog
						.openDialog(
							null,
							PRESENTATION_DISPLAY_WARNING);
				}
				
				// now stop old presentation (if any), do not stop remote presenters
				if (oldPresentationControl != null) {
					oldPresentationControl.removeIf(p -> p instanceof RemotePresenter);
					oldPresentationControl.hidePresenter();
					oldPresentationControl.disposePresenter();
				}
			});
			
			return true;
		}
	}
	
	public void startCountDown(final int seconds, final Song song) {
		Runnable countDownRunnable = () -> {
			LOG.trace("start sleeping for {} seconds (count-down)", seconds);
			try {
				Thread.sleep(seconds * 1000);
				statisticsController.countSongAsPresentedToday(song);
			} catch (InterruptedException e) {
				// if interrupted, do nothing (the countdown was stopped)
				LOG.trace("interrupted (count-down)");
			}
		};
		stopCountDown();
		if (executor.isShutdown()) {
			throw new IllegalStateException("background executor is stopped");
		} else {
			countDownFuture = executor.submit(countDownRunnable);
		}
	}
	
	public void stopCountDown() {
		if (countDownFuture != null) {
			LOG.trace("stopping countdown");
			countDownFuture.cancel(true);
			countDownFuture = null;
		} else {
			LOG.trace("wanted to stop countdown, but nothing to do");
		}
	}
	
	@Override
	public List<AddressablePart> getParts() {
		Preconditions.checkArgument(presentationControl != null, "there is no active presentation");
		return presentationControl.getParts();
	}
	
	@Override
	public void moveToPart(Integer part) {
		if (MainController.this.presentationControl != null) {
			try {
				presentationControl.moveToPart(part);
			} catch (IllegalStateException e) {
				// if song is not displayed yet
			}
		}
	}
	
	@Override
	public void moveToLine(Integer part, Integer line) {
		if (MainController.this.presentationControl != null) {
			try {
				presentationControl.moveToLine(part, line);
			} catch (IllegalStateException e) {
				// if song is not displayed yet
			}
		}
	}
	
	private PresenterWindow createPresenter(SelectableDisplay screen, Presentable presentable, VirtualScreen virtualScreen) {
		if (screen == null || !screen.isAvailable()) {
			// nothing to be done
			LOG.debug("could not create presenter window - display is {}", screen == null ? "null" : "unavailable");
			return null;
		}
		PresenterWindow presenterWindow = null;
		try {
			presenterWindow = new PresenterWindow(screen, presentable, virtualScreen, settings, this);
		} catch (IllegalStateException ise) {
			LOG.warn("could not create presenter window", ise);
			return null;
		}
		
		return presenterWindow;
	}
	
	public List<SelectableDisplay> getScreens() {
		return Collections.unmodifiableList(screens);
	}
	
	public void detectScreens() {
		if (screens == null) {
			screens = new ArrayList<>();
		} else {
			screens.clear();
		}
		
		// for the setting "don't show at all"
		screens.add(null);
		
		List<SelectableDisplay> availableScreens = ScreenHelper.getScreens();
		screens.addAll(availableScreens);
		
		// if applicable: add currently unavailable screens if they are mentioned in settings
		Integer screen1Index = settings.get(SettingKey.SCREEN_1_DISPLAY, Integer.class);
		Integer screen2Index = settings.get(SettingKey.SCREEN_2_DISPLAY, Integer.class);
		int maxScreenIndex = Math.max(screen1Index == null ? 0 : screen1Index, screen2Index == null ? 0 : screen2Index);
		for (int i = availableScreens.size(); i <= maxScreenIndex; i++) {
			LOG.debug("adding screen with index {} as unavailable", i);
			screens.add(new SelectableDisplay(i, false));
		}
	}
	
	private boolean disposePresenter() {
		if (presentationControl != null) {
			presentationControl.hidePresenter();
			presentationControl.disposePresenter();
		}
		return true;
	}
	
	public boolean closeRemoteController() {
		if (remoteController != null) {
			setRemoteStatus(RemoteStatus.DISCONNECTING);
			if (songsController != null && songsController instanceof PatchController) {
				songsController.close();
				songsController = new SongsModelController(songs);
			}
			if (presentationControl != null)
				presentationControl.removeIf(p -> p instanceof RemotePresenter);
			remoteController.close();
			remoteController = null;
			setRemoteStatus(RemoteStatus.OFF);
		}
		return true;
	}
	
	public boolean prepareClose() {
		LOG.debug("preparing to close application");
		return saveAll() && disposePresenter() && closeRemoteController();
	}
	
	public boolean saveAll() {
		boolean successfullySavedDB = songsController == null || songsController.save();
		boolean successfullySavedSongs = saveSongs();
		boolean successfullySavedSettings = saveSettings();
		boolean successfullySavedStatistics = statisticsController.saveStatistics();
		return successfullySavedDB && successfullySavedSongs && successfullySavedSettings && successfullySavedStatistics;
	}
	
	public void shutdown() {
		shutdown(0);
	}
	
	public void shutdown(int exitCode) {
		LOG.debug("closing application, exit code {}", exitCode);
		executor.shutdownNow();
		System.exit(exitCode);
	}
	
	public void loadSongs(String fileName) {
		if (!StringTools.isBlank(fileName)) {
			songsFileName = fileName;
		}
		SongsModel songsLoaded = populateSongsModel(songsFileName);
		if (songsLoaded != null) {
			songsController.update(songsLoaded);
		}
		
		if (shutdownHook != null) {
			Runtime.getRuntime().removeShutdownHook(shutdownHook);
		}
		shutdownHook = new Thread() {
			@Override
			public void run() {
				// don't use LOG here because the logger may already have shut down itself
				try {
					saveSongs();
					System.out.println("done saving songs on regular shutdown");
				} catch (Exception e) {
					System.err.println("could not save songs on regular shutdown: ");
					e.printStackTrace();
				}
			}
		};
		Runtime.getRuntime().addShutdownHook(shutdownHook);
	}
	
	public void startWatchingSongsFile() {
		try {
			LOG.info("starting to watch for changes in {}", songsFileName);
			ioController.startWatching(songsFileName, () -> {
				LOG.info("change in file {} detected", songsFileName);
				
				int selected = JOptionPane.showConfirmDialog(mainWindow, """
					The songs file on disk was changed by another process (maybe remote synchronisation). Should it be reloaded?
					
					Songs already added to the 'Present Songs' tab will remain unchanged. Unsaved changes in the 'Edit Song' tab will be lost.""",
					"Songs file changed on disk", JOptionPane.YES_NO_OPTION);
				if (selected == JOptionPane.YES_OPTION) {
					LOG.info("reloading songs from {}", songsFileName);
					loadSongs(null);
					mainWindow.reloadModels(getSongs(), getSettings());
				}
				
			});
		} catch (Exception e) {
			LOG.warn("could not start watching the songs file", e);
		}
	}
	
	public void exportStatisticsAll(File targetExcelFile) {
		statisticsController.exportStatisticsAll(songs, targetExcelFile);
	}
	
	public void loadSettings() {
		LOG.debug("loading settings from file");
		settings = ioController.readSettings(is -> XMLConverter.fromXMLToPersistable(is));
		if (settings == null) {
			// there was a problem while reading
			SwingUtilities.invokeLater(() -> ErrorDialog.openDialogBlocking(mainWindow, """
				Could not read saved settings!
				
				Started with default values,
				you might have to adjust some settings
				(see tab "Global Settings")."""));
			settings = new SettingsModel();
		}
		loadDefaultSettingsForUnsetSettings();
	}
	
	private void loadDefaultSettingsForUnsetSettings() {
		putDefaultIfKeyIsUnset(SettingKey.BACKGROUND_COLOR, Color.BLACK);
		putDefaultIfKeyIsUnset(SettingKey.TEXT_COLOR, Color.WHITE);
		putDefaultIfKeyIsUnset(SettingKey.BACKGROUND_COLOR_2, settings.get(SettingKey.BACKGROUND_COLOR, Color.class));
		putDefaultIfKeyIsUnset(SettingKey.TEXT_COLOR_2, settings.get(SettingKey.TEXT_COLOR, Color.class));
		
		putDefaultIfKeyIsUnset(SettingKey.TOP_MARGIN, Integer.valueOf(10));
		putDefaultIfKeyIsUnset(SettingKey.LEFT_MARGIN, Integer.valueOf(0));
		putDefaultIfKeyIsUnset(SettingKey.RIGHT_MARGIN, Integer.valueOf(0));
		putDefaultIfKeyIsUnset(SettingKey.BOTTOM_MARGIN, Integer.valueOf(20));
		putDefaultIfKeyIsUnset(SettingKey.DISTANCE_TITLE_TEXT, Integer.valueOf(20));
		putDefaultIfKeyIsUnset(SettingKey.DISTANCE_TEXT_COPYRIGHT, Integer.valueOf(20));
		
		putDefaultIfKeyIsUnset(SettingKey.SONG_LIST_FILTER, FilterTypeEnum.TITLE_AND_LYRICS);
		putDefaultIfKeyIsUnset(SettingKey.SCREEN_1_CONTENTS, ScreenContentsEnum.ONLY_LYRICS);
		putDefaultIfKeyIsUnset(SettingKey.SCREEN_2_CONTENTS, ScreenContentsEnum.LYRICS_AND_CHORDS_AND_CHORD_SEQUENCE);
		List<SelectableDisplay> availableScreens = ScreenHelper.getScreens();
		if (availableScreens.size() > 1) {
			putDefaultIfKeyIsUnset(SettingKey.SCREEN_1_DISPLAY, Integer.valueOf(availableScreens.get(1).getIndex()));
		} else {
			putDefaultIfKeyIsUnset(SettingKey.SCREEN_1_DISPLAY, null);
		}
		putDefaultIfKeyIsUnset(SettingKey.SCREEN_2_DISPLAY, null);
		putDefaultIfKeyIsUnset(SettingKey.MINIMAL_SCROLLING, Boolean.FALSE);
		putDefaultIfKeyIsUnset(SettingKey.MINIMAL_SCROLLING_2, Boolean.FALSE);
		
		putDefaultIfKeyIsUnset(SettingKey.SHOW_TITLE, Boolean.TRUE);
		putDefaultIfKeyIsUnset(SettingKey.TITLE_FONT, new Font(Font.SERIF, Font.BOLD, 20));
		putDefaultIfKeyIsUnset(SettingKey.LYRICS_FONT, new Font(Font.SERIF, Font.PLAIN, 20));
		putDefaultIfKeyIsUnset(SettingKey.TRANSLATION_FONT, new Font(Font.SERIF, Font.PLAIN, 20));
		putDefaultIfKeyIsUnset(SettingKey.COPYRIGHT_FONT, new Font(Font.SERIF, Font.ITALIC, 20));
		putDefaultIfKeyIsUnset(SettingKey.CHORD_SEQUENCE_FONT, new Font(Font.SERIF, Font.ITALIC, 20));
		putDefaultIfKeyIsUnset(SettingKey.TITLE_FONT_2, settings.get(SettingKey.TITLE_FONT, Font.class));
		putDefaultIfKeyIsUnset(SettingKey.LYRICS_FONT_2, settings.get(SettingKey.LYRICS_FONT, Font.class));
		putDefaultIfKeyIsUnset(SettingKey.TRANSLATION_FONT_2, settings.get(SettingKey.TRANSLATION_FONT, Font.class));
		putDefaultIfKeyIsUnset(SettingKey.COPYRIGHT_FONT_2, settings.get(SettingKey.COPYRIGHT_FONT, Font.class));
		putDefaultIfKeyIsUnset(SettingKey.CHORD_SEQUENCE_FONT_2, settings.get(SettingKey.CHORD_SEQUENCE_FONT, Font.class));
		putDefaultIfKeyIsUnset(SettingKey.LOGO_FILE, "");
		putDefaultIfKeyIsUnset(SettingKey.SECONDS_UNTIL_COUNTED, Integer.valueOf(60));
		
		putDefaultIfKeyIsUnset(SettingKey.SLIDE_SHOW_DIRECTORY, null);
		putDefaultIfKeyIsUnset(SettingKey.SLIDE_SHOW_SECONDS_UNTIL_NEXT_PICTURE, Integer.valueOf(20));
		
		putDefaultIfKeyIsUnset(SettingKey.REMOTE_ENABLED, false);
		putDefaultIfKeyIsUnset(SettingKey.REMOTE_PASSWORD, "");
		putDefaultIfKeyIsUnset(SettingKey.REMOTE_SERVER, "tcp://localhost:1883");
		putDefaultIfKeyIsUnset(SettingKey.REMOTE_USERNAME, "");
		putDefaultIfKeyIsUnset(SettingKey.REMOTE_PREFIX, "");
		putDefaultIfKeyIsUnset(SettingKey.REMOTE_NAMESPACE, "default");
		
		// check that really all settings are set
		for (SettingKey key : SettingKey.values()) {
			if (!settings.isSet(key)) {
				throw new IllegalStateException("unset value for setting key: " + key);
			}
		}
	}
	
	private void putDefaultIfKeyIsUnset(SettingKey key, Object defaultValue) {
		if (!settings.isSet(key)) {
			settings.put(key, defaultValue);
		}
	}
	
	public synchronized boolean saveSettings() {
		// TODO move method to IOController !?
		File file = new File(FileAndDirectoryLocations.getSettingsFileName());
		try {
			OutputStream xmlOutputStream = new FileOutputStream(file);
			XMLConverter.fromPersistableToXML(settings, xmlOutputStream);
			xmlOutputStream.close();
			return true;
		} catch (IOException e) {
			LOG.error("could not write settings to \"" + file.getAbsolutePath() + "\"");
			return false;
		}
	}
	
	private SongsModel populateSongsModel(String fileName) {
		LOG.debug("loading songs from file {}", fileName);
		SongsModel songsModel = ioController.readSongs(fileName, is -> XMLConverter.fromXMLToPersistable(is));
		if (songsModel != null) {
			return songsModel;
		} else {
			String fileNameUsed = FileAndDirectoryLocations.getSongsFileName(fileName);
			LOG.error("could not load songs from {}", fileNameUsed);
			ErrorDialog.openDialogBlocking(null, "Could not load songs from file:\n" + fileNameUsed
				+ "\n\nThis is a fatal error, exiting.\nSee log file for more details.");
			shutdown(-1);
			return null;
		}
	}
	
	public synchronized boolean saveSongs() {
		// TODO move method to IOController !?
		File songsBackupFile = saveSongsToBackupFile();
		if (songsBackupFile == null) {
			LOG.error("could not write backup file while saving database");
			ErrorDialog.openDialog(null, """
				Could not save songs!
				
				(Phase 1 - write backup file)""");
			return false;
		}
		try {
			ioController.stopWatching();
			Path source = songsBackupFile.toPath();
			Path target = Paths.get(FileAndDirectoryLocations.getSongsFileName(songsFileName));
			LOG.info("copying {} to {}", source, target);
			Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
		} catch (IOException e) {
			LOG.error("could not copy backup to real file while saving database");
			ErrorDialog.openDialog(null, """
				Could not save songs!
				
				(Phase 2 - write file)""");
			return false;
		} finally {
			ioController.startWatchingAgain();
		}
		
		manageOldBackups();
		
		return true;
	}
	
	private File saveSongsToBackupFile() {
		// TODO move method to IOController !?
		File file = new File(FileAndDirectoryLocations.getSongsBackupFile());
		try (OutputStream xmlOutputStream = new FileOutputStream(file)) {
			LOG.debug("writing songs to backup file \"{}\"", file.getAbsolutePath());
			XMLConverter.fromPersistableToXML(songs, xmlOutputStream);
			return file;
		} catch (IOException e) {
			LOG.error("could not write songs to backup file \"" + file.getAbsolutePath() + "\"", e);
			return null;
		}
	}
	
	/**
	 * delete backup files older than 21 days, but retain 30 backups at least
	 */
	private void manageOldBackups() {
		// TODO move method to IOController !?
		try {
			Files.list(Paths.get(FileAndDirectoryLocations.getSongsBackupDir()))
				// ordering: firstly by modification date DESC, secondly by file name DESC
				.sorted((p1, p2) -> ComparisonChain.start()
					.compare(lastModified(p1), lastModified(p2), Ordering.natural().reversed())
					.compare(p1, p2, Ordering.natural().reversed())
					.result())
				.skip(30)
				.filter(p -> lastModified(p).isBefore(LocalDateTime.now().minusDays(21)))
				.forEach(p -> {
					LOG.info("deleting old backup {}", p);
					delete(p);
				});
		} catch (Exception e) {
			LOG.warn("error while managing backup files");
		}
	}
	
	private LocalDateTime lastModified(Path path) {
		try {
			return Instant.ofEpochMilli(Files.getLastModifiedTime(path).toMillis()).atZone(ZoneId.systemDefault()).toLocalDateTime();
		} catch (IOException e) {
			throw new RuntimeException(e);
		}
	}
	
	private void delete(Path p) {
		try {
			Files.delete(p);
		} catch (IOException e) {
			throw new RuntimeException(e);
		}
	}
	
	public SongsModel getSongs() {
		return songs;
	}
	
	public SettingsModel getSettings() {
		return settings;
	}
	
	/**
	 * Use a nice LaF.
	 *
	 * @return {@code true} if the LaF could be applied, {@code false} otherwise
	 */
	public boolean setupLookAndFeel() {
		try {
			for (LookAndFeelInfo info : UIManager.getInstalledLookAndFeels()) {
				if ("Nimbus".equals(info.getName())) {
					UIManager.setLookAndFeel(info.getClassName());
					break;
				}
			}
			return true;
		} catch (ClassNotFoundException | InstantiationException | IllegalAccessException
			| UnsupportedLookAndFeelException e) {
			LOG.warn("could not apply the look-and-feel");
			return false;
		}
	}
	
	public Song getCurrentlyPresentedSong() {
		return currentlyPresentedSong;
	}
	
	public static void initAnimationTimer() {
		final TimingSource animationTimer = new SwingTimerTimingSource(5, TimeUnit.MILLISECONDS);
		Animator.setDefaultTimingSource(animationTimer);
		animationTimer.init();
	}
	
	public void startSlideShowCycle(final int seconds) {
		Runnable slideShowRunnable = () -> {
			LOG.debug("start sleeping for {} seconds (slide show)", seconds);
			try {
				if (slideShowImages == null || !slideShowImages.hasNext()) {
					LOG.info("no image files available for slide show");
					return;
				}
				showSlide(slideShowImages.next());
				Thread.sleep(seconds * 1000);
				startSlideShowCycle(seconds);
			} catch (InterruptedException e) {
				// if interrupted, do nothing (the slide show was stopped)
				LOG.trace("interrupted (slide show)");
			}
		};
		if (executor.isShutdown()) {
			throw new IllegalStateException("background executor is stopped");
		} else {
			slideShowFuture = executor.submit(slideShowRunnable);
		}
	}
	
	public String loadLogo() {
		String logoPath = settings.get(SettingKey.LOGO_FILE, String.class);
		if (logoPath != null && !logoPath.equals("")) {
			File logoFile = new File(logoPath);
			return logoFile.getAbsolutePath();
		}
		return null;
	}
	
	private void showSlide(File imageFile) {
		present(new Presentable(null, imageFile.getAbsolutePath()));
	}
	
	public void stopSlideShow() {
		if (slideShowFuture != null) {
			LOG.debug("stopping slide show");
			slideShowFuture.cancel(true);
			slideShowFuture = null;
			slideShowImages = null;
		} else {
			LOG.trace("wanted to stop slide show, but nothing to do");
		}
	}
	
	public boolean presentSlideShow() {
		Path imageDirectory = Paths.get(settings.get(SettingKey.SLIDE_SHOW_DIRECTORY, String.class));
		if (imageDirectory == null || !Files.exists(imageDirectory) || !Files.isReadable(imageDirectory) || !Files.isDirectory(imageDirectory)) {
			LOG.warn("directory {} could not be opened", imageDirectory);
			return false;
		}
		
		slideShowImages = getImageIterator(imageDirectory);
		if (slideShowImages == null) {
			LOG.warn("images could not be loaded from directory {}", imageDirectory.toString());
			return false;
		}
		
		Integer seconds = settings.get(SettingKey.SLIDE_SHOW_SECONDS_UNTIL_NEXT_PICTURE, Integer.class);
		if (seconds == null) {
			LOG.warn("slide show settings: seconds until next picture missing");
			return false;
		}
		
		// stop the "old" slide show if it is still running
		stopSlideShow();
		
		startSlideShowCycle(seconds);
		return true;
	}
	
	private Iterator<File> getImageIterator(Path imageDirectory) {
		try {
			List<File> images = Files.list(imageDirectory)
				.map(path -> path.toFile())
				.filter(file -> imagePattern.matcher(file.getName()).matches())
				.sorted()
				.toList();
			return Iterables.cycle(images).iterator();
		} catch (IOException e) {
			return null;
		}
	}
	
	public RemoteController getRemoteController() {
		return remoteController;
	}
	
	public SongsModelController getSongsController() {
		return songsController;
	}
	
} public enum Modifiers {
	NONE(false, false, false),
	SHIFT(true, false, false),
	ALT(false, true, false),
	CTRL(false, false, true),
	SHIFT_ALT(true, true, false),
	CTRL_ALT(false, true, true),
	SHIFT_CTRL(true, false, true),
	SHIFT_ALT_CTRL(true, true, true);
		
	private final boolean shift;
	private final boolean alt;
	private final boolean ctrl;
		
	private Modifiers(boolean shift, boolean alt, boolean ctrl) {
		this.shift = shift;
		this.alt = alt;
		this.ctrl = ctrl;
	}
		
	public boolean hasShift() {
		return shift;
	}
		
	public boolean hasAlt() {
		return alt;
	}
		
	public boolean hasCtrl() {
		return ctrl;
	}
} public class FilterTypeCellRenderer implements ListCellRenderer<FilterTypeEnum> {
	
	private static final Logger LOG = LoggerFactory.getLogger(FilterTypeCellRenderer.class);
	
	protected DefaultListCellRenderer defaultRenderer = new DefaultListCellRenderer();
	
	@Override
	public Component getListCellRendererComponent(JList<? extends FilterTypeEnum> list, FilterTypeEnum value,
		int index, boolean isSelected, boolean cellHasFocus) {
		JLabel ret = (JLabel) defaultRenderer.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus);
		if (value == null) {
			ret.setText("");
		} else if (value == FilterTypeEnum.ONLY_LYRICS) {
			ret.setText("only lyrics");
		} else if (value == FilterTypeEnum.ONLY_TITLE) {
			ret.setText("only title");
		} else if (value == FilterTypeEnum.TITLE_AND_LYRICS) {
			ret.setText("title and lyrics");
		} else {
			LOG.warn("unknown filter type enum value: {}", value.toString());
			ret.setText(value.name());
		}
		return ret;
	}
} public class ScreenContentsCellRenderer implements ListCellRenderer<ScreenContentsEnum> {
	
	protected DefaultListCellRenderer defaultRenderer = new DefaultListCellRenderer();
	
	@Override
	public Component getListCellRendererComponent(JList<? extends ScreenContentsEnum> list, ScreenContentsEnum value,
		int index, boolean isSelected, boolean cellHasFocus) {
		JLabel ret = (JLabel) defaultRenderer.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus);
		if (value == null) {
			ret.setText("");
		} else {
			ret.setText(value.getDescription());
		}
		return ret;
	}
} public class ScreenDisplayCellRenderer implements ListCellRenderer<SelectableDisplay> {
	
	protected DefaultListCellRenderer defaultRenderer = new DefaultListCellRenderer();
	
	@Override
	public Component getListCellRendererComponent(JList<? extends SelectableDisplay> list, SelectableDisplay value,
		int index, boolean isSelected, boolean cellHasFocus) {
		JLabel ret = (JLabel) defaultRenderer.getListCellRendererComponent(list, value, index, isSelected,
			cellHasFocus);
		if (value == null) {
			ret.setText("hidden");
		} else {
			ret.setText(value.getDescription());
		}
		return ret;
	}
} public class SongCellRenderer implements ListCellRenderer<Song> {
	
	private static final long serialVersionUID = -9042262843850129406L;
	
	private static final Logger LOG = LoggerFactory.getLogger(SongCellRenderer.class);
	
	private JTextComponent filterInputField;
	private SongCellHighlighter highlighter;
	
	public SongCellRenderer(JTextComponent filterInputField) {
		this.filterInputField = filterInputField;
		
		if (Feature.HIGHLIGHT_FILTER_MATCHES.isActive()) {
			highlighter = new SongCellHighlighter();
			updateHighlighter();
			
			this.filterInputField.getDocument().addDocumentListener(new DocumentListener() {
				@Override
				public void removeUpdate(DocumentEvent e) {
					updateHighlighter();
				}
				
				@Override
				public void insertUpdate(DocumentEvent e) {
					updateHighlighter();
				}
				
				@Override
				public void changedUpdate(DocumentEvent e) {
					updateHighlighter();
				}
			});
		}
	}
	
	private void updateHighlighter() {
		if (Feature.HIGHLIGHT_FILTER_MATCHES.isActive()) {
			String findRegex = constructFindRegex(filterInputField.getText());
			String replaceRegex = constructReplaceRegex(filterInputField.getText());
			LOG.debug("new find regex: {}", findRegex);
			LOG.debug("new replace regex: {}", replaceRegex);
			highlighter.setFindRegex(findRegex);
			highlighter.setReplaceRegex(replaceRegex);
		}
	}
	
	private String constructFindRegex(String filter) {
		if (StringUtils.isBlank(filter)) {
			return "";
		}
		StringBuilder ret = new StringBuilder();
		ret.append("(?i)^(.*)(");
		String[] filterParts = filter.toLowerCase().split(IndexerService.TERM_SPLIT_REGEX);
		boolean isFirst = true;
		for (String filterPart : filterParts) {
			if (isFirst) {
				isFirst = false;
			} else {
				ret.append(")([^ ]* [^ ]*)(");
			}
			ret.append(filterPart);
		}
		ret.append(")(.*)$");
		return ret.toString();
	}
	
	private String constructReplaceRegex(String filter) {
		if (StringUtils.isBlank(filter)) {
			return "";
		}
		StringBuilder ret = new StringBuilder();
		ret.append("<html>$1<b>");
		String[] filterParts = filter.toLowerCase().split(IndexerService.TERM_SPLIT_REGEX);
		int matchNumber = 2;
		for (@SuppressWarnings("unused")
		String filterPart : filterParts) {
			if (matchNumber > 2) {
				ret.append("</b>$" + matchNumber++ + "<b>");
			}
			ret.append("$" + matchNumber++);
		}
		ret.append("</b>$").append(matchNumber).append("</html>");
		return ret.toString();
	}
	
	@Override
	public Component getListCellRendererComponent(JList<? extends Song> list, Song value, int index,
		boolean isSelected, boolean cellHasFocus) {
		
		SongCell ret = new SongCell(30);
		if (value != null) {
			ret.setSongTitle(value.getTitle() != null ? value.getTitle() : "");
			ret.setFirstLine(SongParser.getFirstLyricsLine(value));
		}
		
		ret.setForeground(Color.BLACK);
		if (isSelected) {
			ret.setBackground(new Color(183, 200, 213));
		} else {
			if (index % 2 == 0) {
				ret.setBackground(Color.WHITE);
			} else {
				ret.setBackground(new Color(230, 230, 230));
			}
		}
		
		if (Feature.HIGHLIGHT_FILTER_MATCHES.isActive()) {
			highlighter.highlight(ret);
		}
		
		return ret;
	}
} public class AddressablePart implements Addressable, Iterable<AddressableLine> {
	
	private List<AddressableLine> lines = new ArrayList<>();
	
	public int size() {
		return lines.size();
	}
	
	public boolean isEmpty() {
		return lines.isEmpty();
	}
	
	public boolean add(AddressableLine e) {
		return lines.add(e);
	}
	
	public AddressableLine get(int index) {
		return lines.get(index);
	}
	
	@Override
	public Integer getPosition() {
		if (isEmpty()) {
			throw new IllegalStateException("an AddressablePart must contain at least one AddressableLine");
		}
		return get(0).getPosition();
	}
	
	@Override
	public String toString() {
		StringBuilder ret = new StringBuilder("PART[");
		boolean isFirst = true;
		for (AddressableLine line : lines) {
			if (isFirst) {
				isFirst = false;
			} else {
				ret.append(", ");
			}
			ret.append(line.toString());
		}
		ret.append("]");
		return ret.toString();
	}
	
	@Override
	public Iterator<AddressableLine> iterator() {
		return lines.iterator();
	}
	
} public class ExportFormat {
	private boolean showTranslation;
	private boolean showChords;
	private boolean onlySongsWithChords;
	
	public ExportFormat(boolean showTranslation, boolean showChords, boolean onlySongsWithChords) {
		this.showTranslation = showTranslation;
		this.showChords = showChords;
		this.onlySongsWithChords = onlySongsWithChords;
	}
	
	public boolean isTranslationShown() {
		return showTranslation;
	}
	
	public boolean areChordsShown() {
		return showChords;
	}
	
	public boolean onlySongsWithChords() {
		return onlySongsWithChords;
	}
} public enum FilterTypeEnum {
	ONLY_TITLE(FieldName.TITLE), TITLE_AND_LYRICS(FieldName.TITLE, FieldName.LYRICS), ONLY_LYRICS(FieldName.LYRICS);
	
	private FieldName[] fields;
	
	private FilterTypeEnum(FieldName... fields) {
		this.fields = fields;
	}
	
	public FieldName[] getFields() {
		return fields;
	}
	
	public String getInternalName() {
		return WordUtils.capitalizeFully(name(), new char[] { '_' }).replaceAll("_", "");
	}
	
	public static FilterTypeEnum withInternalName(String internalName) {
		for (FilterTypeEnum fte : values()) {
			if (fte.getInternalName().equalsIgnoreCase(internalName)) {
				return fte;
			}
		}
		return null;
	}
} public enum ScreenContentsEnum {
	ONLY_LYRICS("only lyrics", false, false, false),
	LYRICS_AND_CHORDS("lyrics and chords", false, true, false),
	LYRICS_AND_CHORD_SEQUENCE("lyrics and chord sequence", false, false, true),
	LYRICS_AND_CHORDS_AND_CHORD_SEQUENCE("lyrics, chords and chord sequence", false, true, true),
	LYRICS_AND_TRANSLATION("lyrics and translation", true, false, false),
	LYRICS_AND_TRANSLATION_AND_CHORDS("lyrics, translation and chords", true, true, false),
	LYRICS_AND_TRANSLATION_AND_CHORD_SEQUENCE("lyrics, translation and chord sequence", true, false, true),
	LYRICS_AND_TRANSLATION_AND_CHORDS_AND_CHORD_SEQUENCE("lyrics, translation, chords and chord sequence", true, true, true);
	
	private final String description;
	private final boolean showTranslation;
	private final boolean showChords;
	private final boolean showChordSequence;
	
	private ScreenContentsEnum(String description, boolean showTranslation, boolean showChords, boolean showChordSequence) {
		this.description = description;
		this.showTranslation = showTranslation;
		this.showChords = showChords;
		this.showChordSequence = showChordSequence;
	}
	
	public String getDescription() {
		return description;
	}
	
	public boolean shouldShowTranslation() {
		return showTranslation;
	}
	
	public boolean shouldShowChords() {
		return showChords;
	}
	
	public boolean shouldShowChordSequence() {
		return showChordSequence;
	}
	
	public String getInternalName() {
		return WordUtils.capitalizeFully(name(), new char[] { '_' }).replaceAll("_", "");
	}
	
	public static ScreenContentsEnum withInternalName(String internalName) {
		for (ScreenContentsEnum sce : values()) {
			if (sce.getInternalName().equalsIgnoreCase(internalName)) {
				return sce;
			}
		}
		return null;
	}
} public class SelectableDisplay {
	
	private final int index;
	private boolean available;
	
	/**
	 * @param index
	 *            0-based (display 0 is the main display)
	 */
	public SelectableDisplay(int index) {
		this(index, true);
	}
	
	/**
	 * @param index
	 *            0-based (display 0 is the main display)
	 * @param available
	 *            is this display available at present?
	 */
	public SelectableDisplay(int index, boolean available) {
		Preconditions.checkArgument(index >= 0);
		this.index = index;
		this.available = available;
	}
	
	public int getIndex() {
		return index;
	}
	
	public boolean isAvailable() {
		return available;
	}
	
	public void setAvailable(boolean available) {
		this.available = available;
	}
	
	/**
	 * @return description including a display number - attention: this number is 1-based and not 0-based as the
	 *         {@link #index}, so the display with index 1 will have a description like "display 2"!
	 */
	public String getDescription() {
		return "Display " + (index + 1) + (index == 0 ? " (main display)" : "") + (available ? "" : " - currently unavailable");
	}
	
	@Override
	public String toString() {
		return getDescription();
	}
	
	@Override
	public int hashCode() {
		final int prime = 31;
		int result = 1;
		result = prime * result + index;
		return result;
	}
	
	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		SelectableDisplay other = (SelectableDisplay) obj;
		if (index != other.index)
			return false;
		return true;
	}
	
} @XmlRootElement(name = "song")
@XmlAccessorType(XmlAccessType.NONE)
@XmlAccessorOrder(XmlAccessOrder.ALPHABETICAL)
public class Song implements Serializable, Comparable<Song>, Persistable {
	
	private static final long serialVersionUID = -7133402923581521674L;
	
	@XmlElement(name = "title")
	private String title;
	@XmlElement(name = "composer")
	private String composer;
	@XmlElement(name = "authorText")
	private String authorText;
	@XmlElement(name = "authorTranslation")
	private String authorTranslation;
	@XmlElement(name = "publisher")
	private String publisher;
	@XmlElement(name = "additionalCopyrightNotes")
	private String additionalCopyrightNotes;
	@XmlElement(name = "language")
	private String language;
	@XmlElement(name = "songNotes")
	private String songNotes;
	@XmlElement(name = "tonality")
	private String tonality;
	@XmlElement(name = "uuid")
	private String uuid;
	@XmlElement(name = "chordSequence")
	private String chordSequence;
	@XmlElement(name = "drumNotes")
	private String drumNotes;
	@XmlElement(name = "tempo")
	private String tempo;
	@XmlElement(name = "lyrics")
	private String lyrics;
	
	/**
	 * Create a song instance. CAUTION: every song has to have a UUID! This constructor is only necessary for
	 * unmarshalling from XML.
	 */
	public Song() {
		initIfNecessary();
		// default constructor
	}
	
	public Song(Map<String, String> map) {
		this();
		this.fromMap(map);
	}
	
	public Song(Song song) {
		this();
		title = song.getTitle();
		composer = song.getComposer();
		authorText = song.getAuthorText();
		authorTranslation = song.getAuthorTranslation();
		publisher = song.getPublisher();
		additionalCopyrightNotes = song.getAdditionalCopyrightNotes();
		language = song.getLanguage();
		songNotes = song.getSongNotes();
		tonality = song.getTonality();
		uuid = song.getUUID();
		chordSequence = song.getChordSequence();
		drumNotes = song.getDrumNotes();
		tempo = song.getTempo();
		lyrics = song.getLyrics();
	}
	
	/**
	 * Create a song instance.
	 *
	 * @param uuid
	 *            a UUID for this song
	 * @see StringTools#createUUID()
	 */
	public Song(String uuid) {
		this();
		this.uuid = uuid;
	}
	
	public String getTitle() {
		return title;
	}
	
	public String getComposer() {
		return composer;
	}
	
	public String getAuthorText() {
		return authorText;
	}
	
	public String getAuthorTranslation() {
		return authorTranslation;
	}
	
	public String getPublisher() {
		return publisher;
	}
	
	public String getAdditionalCopyrightNotes() {
		return additionalCopyrightNotes;
	}
	
	public String getLanguage() {
		return language;
	}
	
	public String getSongNotes() {
		return songNotes;
	}
	
	public String getLyrics() {
		return lyrics;
	}
	
	public String getTonality() {
		return tonality;
	}
	
	public String getTempo() {
		return tempo;
	}
	
	public String getChordSequence() {
		return chordSequence;
	}
	
	public String getDrumNotes() {
		return drumNotes;
	}
	
	public String getCleanChordSequence() {
		return chordSequence == null
			? null
			: chordSequence.replaceAll("^\\p{Space}+", "").replaceAll("\\p{Space}+$", "");
	}
	
	public void setTitle(String title) {
		this.title = title;
	}
	
	public void setComposer(String composer) {
		this.composer = composer;
	}
	
	public void setAuthorText(String authorText) {
		this.authorText = authorText;
	}
	
	public void setAuthorTranslation(String authorTranslation) {
		this.authorTranslation = authorTranslation;
	}
	
	public void setPublisher(String publisher) {
		this.publisher = publisher;
	}
	
	public void setAdditionalCopyrightNotes(String additionalCopyrightNotes) {
		this.additionalCopyrightNotes = additionalCopyrightNotes;
	}
	
	public void setLanguage(String language) {
		this.language = language;
	}
	
	public void setSongNotes(String songNotes) {
		this.songNotes = songNotes;
	}
	
	public void setLyrics(String lyrics) {
		this.lyrics = lyrics;
	}
	
	public void setTonality(String tonality) {
		this.tonality = tonality;
	}
	
	public void setChordSequence(String chordSequence) {
		this.chordSequence = chordSequence;
	}
	
	public String getUUID() {
		return uuid;
	}
	
	public void setTempo(String tempo) {
		this.tempo = tempo;
	}
	
	public void setDrumNotes(String drumNotes) {
		this.drumNotes = drumNotes;
	}
	
	private void setUUID(String uuid) {
		this.uuid = uuid;
	}
	
	@Override
	public int compareTo(Song o) {
		int ret = 0;
		
		ret = StringTools.compareLocaleBasedWithNullFirst(getTitle(), o.getTitle());
		if (ret != 0) {
			return ret;
		}
		
		ret = StringTools.compareLocaleBasedWithNullFirst(getLyrics(), o.getLyrics());
		if (ret != 0) {
			return ret;
		}
		
		ret = StringTools.compareLocaleBasedWithNullFirst(getChordSequence(), o.getChordSequence());
		if (ret != 0) {
			return ret;
		}
		
		return ret;
	}
	
	@Override
	public int hashCode() {
		final int prime = 31;
		int result = 1;
		result = prime * result + ((chordSequence == null) ? 0 : chordSequence.hashCode());
		result = prime * result + ((lyrics == null) ? 0 : lyrics.hashCode());
		result = prime * result + ((title == null) ? 0 : title.hashCode());
		return result;
	}
	
	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		Song other = (Song) obj;
		return equalsAllowNull(uuid, other.uuid) &&
			equalsAllowNull(title, other.title) &&
			equalsAllowNull(composer, other.composer) &&
			equalsAllowNull(authorText, other.authorText) &&
			equalsAllowNull(authorTranslation, other.authorTranslation) &&
			equalsAllowNull(publisher, other.publisher) &&
			equalsAllowNull(additionalCopyrightNotes, other.additionalCopyrightNotes) &&
			equalsAllowNull(language, other.language) &&
			equalsAllowNull(songNotes, other.songNotes) &&
			equalsAllowNull(tonality, other.tonality) &&
			equalsAllowNull(chordSequence, other.chordSequence) &&
			equalsAllowNull(drumNotes, other.drumNotes) &&
			equalsAllowNull(tempo, other.tempo) &&
			equalsAllowNull(lyrics, other.lyrics);
	}
	
	@Override
	public String toString() {
		return "SONG[" + title + "|" + uuid + "]";
	}
	
	private static boolean equalsAllowNull(String str, String str2) {
		if (str == null || str.isEmpty()) {
			if (str2 != null && !str2.isEmpty()) {
				return false;
			}
		} else if (!str.equals(str2)) {
			return false;
		}
		return true;
	}
	
	private static boolean isEmpty(String str) {
		return str == null || str.isEmpty();
	}
	
	public boolean isEmpty() {
		return isEmpty(getTitle())
			&& isEmpty(getComposer())
			&& isEmpty(getAuthorText())
			&& isEmpty(getAuthorTranslation())
			&& isEmpty(getPublisher())
			&& isEmpty(getAdditionalCopyrightNotes())
			&& isEmpty(getLanguage())
			&& isEmpty(getSongNotes())
			&& isEmpty(getLyrics())
			&& isEmpty(getTonality())
			&& isEmpty(getTempo())
			&& isEmpty(getDrumNotes())
			&& isEmpty(getChordSequence());
	}
	
	public Map<String, String> toMap() {
		return new HashMap<>() {
			private static final long serialVersionUID = -4450093906395824130L;
			{
				put("uuid", getUUID());
				put("title", getTitle());
				put("composer", getComposer());
				put("authorText", getAuthorText());
				put("authorTranslation", getAuthorTranslation());
				put("publisher", getPublisher());
				put("additionalCopyrightNotes", getAdditionalCopyrightNotes());
				put("language", getLanguage()); // .getInternalName()
				put("songNotes", getSongNotes());
				put("lyrics", getLyrics());
				put("tonality", getTonality());
				put("tempo", getTempo());
				put("drumNotes", getDrumNotes());
				put("chordSequence", getChordSequence());
			}
		};
	}
	
	public void fromMap(Map<String, String> map) {
		for (Map.Entry<String, String> entry : map.entrySet()) {
			String value = entry.getValue();
			switch (entry.getKey()) {
				case "uuid":
					setUUID(value);
					break;
				case "title":
					setTitle(value);
					break;
				case "composer":
					setComposer(value);
					break;
				case "authorText":
					setAuthorText(value);
					break;
				case "authorTranslation":
					setAuthorTranslation(value);
					break;
				case "publisher":
					setPublisher(value);
					break;
				case "additionalCopyrightNotes":
					setAdditionalCopyrightNotes(value);
					break;
				case "language":
					setLanguage(value);
					break;
				case "songNotes":
					setSongNotes(value);
					break;
				case "lyrics":
					setLyrics(value);
					break;
				case "tonality":
					setTonality(value);
					break;
				case "tempo":
					setTempo(value);
					break;
				case "drumNotes":
					setDrumNotes(value);
					break;
				case "chordSequence":
					setChordSequence(value);
					break;
			}
		}
	}
	
	@Override
	public void initIfNecessary() {
		// nothing to do
	}
} @XmlRootElement(name = "songs")
@XmlAccessorType(XmlAccessType.NONE)
@XmlAccessorOrder(XmlAccessOrder.ALPHABETICAL)
public class SongsModel implements Iterable<Song>, Persistable {
	
	private static final Logger LOG = LoggerFactory.getLogger(SongsModel.class);
	
	private boolean autoSort = false;
	
	@XmlElement(name = "song")
	private List<Song> songs = null;
	
	private List<TransparentListModel<Song>> createdListModels = null;
	
	private List<SongsModelListener> songsModelListeners = null;
	
	public SongsModel() {
		initIfNecessary();
	}
	
	public SongsModel(SongsModel songsModel) {
		this();
		setAutoSort(songsModel.isAutoSort());
		update(songsModel);
	}
	
	public SongsModel(boolean autosort) {
		this();
		this.autoSort = autosort;
	}
	
	public SongsModel(Collection<Song> songs, boolean autosort) {
		this();
		this.songs.addAll(songs);
		this.autoSort = autosort;
	}
	
	/**
	 * Should this model instance sort the songs automatically? Default: {@code true}
	 *
	 * @param value
	 *            the value to set
	 */
	public void setAutoSort(boolean value) {
		this.autoSort = value;
	}
	
	public boolean isAutoSort() {
		return autoSort;
	}
	
	@Override
	public final void initIfNecessary() {
		if (songs == null) {
			songs = new ArrayList<>();
		}
		if (autoSort) {
			sortSongs();
		}
		if (createdListModels == null) {
			createdListModels = new ArrayList<>();
		}
		if (songsModelListeners == null) {
			songsModelListeners = new ArrayList<>();
		}
	}
	
	public TransparentListModel<Song> getListModel() {
		TransparentListModel<Song> createdListModel = new TransparentListModel<>(songs);
		createdListModels.add(createdListModel);
		return createdListModel;
	}
	
	public TransparentFilterableListModel<Song> getFilterableListModel() {
		TransparentFilterableListModel<Song> createdListModel = new TransparentFilterableListModel<>(songs);
		createdListModels.add(createdListModel);
		return createdListModel;
	}
	
	public int getSize() {
		return songs.size();
	}
	
	public boolean isEmpty() {
		return songs.isEmpty();
	}
	
	public boolean addSong(Song e) {
		boolean b = songs.add(e);
		if (autoSort) {
			sortSongs();
		}
		notifyListModelListeners();
		return b;
	}
	
	public void insertSong(int index, Song e) {
		if (autoSort) {
			throw new IllegalStateException("should insert song at a specific position, but auto-sorting is enabled");
		} else {
			songs.add(index, e);
			notifyListModelListeners();
		}
	}
	
	public boolean removeSong(Song o) {
		boolean removed = songs.remove(o);
		if (removed) {
			if (autoSort) {
				sortSongs();
			}
			notifyListModelListeners();
		}
		return removed;
	}
	
	public Song removeSong(int index) {
		Song ret = songs.remove(index);
		if (autoSort) {
			sortSongs();
		}
		notifyListModelListeners();
		return ret;
	}
	
	public void update(SongsModel newModel) {
		songs.clear();
		songs.addAll(newModel.getSongs());
		if (autoSort) {
			sortSongs();
		}
		notifyListModelListeners();
	}
	
	public void clear() {
		songs.clear();
		notifyListModelListeners();
	}
	
	private void notifyListModelListeners() {
		LOG.trace("notifyListModelListeners");
		for (TransparentListModel<Song> model : createdListModels) {
			ListDataListener[] listeners = model.getListDataListeners();
			for (ListDataListener listener : listeners) {
				listener.contentsChanged(new ListDataEvent(this, ListDataEvent.CONTENTS_CHANGED, 0, songs.size() - 1));
			}
		}
		for (SongsModelListener listener : songsModelListeners) {
			listener.songsModelChanged();
		}
	}
	
	public Song getByUUID(String uuid) {
		for (Song song : songs) {
			if (song.getUUID() != null && song.getUUID().equals(uuid)) {
				return song;
			}
		}
		return null;
	}
	
	/**
	 * Update multiple songs in songs by adding them as new ones, or updating them field by field.
	 *
	 * To remove songs, simply use empty ones.
	 *
	 * @param changedSongs
	 * @return number of actually changed songs
	 */
	public List<Song> updateSongsByUUID(Iterable<Song> changedSongs) {
		List<Song> actuallyChangedSongs = new ArrayList<>();
		for (Song song : changedSongs) {
			Song found = getByUUID(song.getUUID());
			if (song.isEmpty()) {
				if (found != null && songs.remove(found))
					actuallyChangedSongs.add(song);
			} else if (found == null) {
				if (songs.add(song))
					actuallyChangedSongs.add(song);
			} else if (!found.equals(song)) {
				songs.remove(found);
				songs.add(song);
				// found.fromMap(song.toMap());
				actuallyChangedSongs.add(song);
			}
		}
		if (!actuallyChangedSongs.isEmpty()) {
			if (autoSort)
				sortSongs();
			notifyListModelListeners();
		}
		return actuallyChangedSongs;
	}
	
	@Override
	public Iterator<Song> iterator() {
		return songs.iterator();
	}
	
	private void sortSongs() {
		Collections.sort(songs);
	}
	
	public Collection<Song> getSongs() {
		return Collections.unmodifiableList(songs);
	}
	
	public void addSongsModelListener(SongsModelListener songsModelListener) {
		songsModelListeners.add(songsModelListener);
	}
	
	@Override
	public boolean equals(Object model2) {
		return model2 instanceof SongsModel sm && getSongs().equals(sm.getSongs());
	}
	
	/**
	 * @param selectedIndex
	 * @param newIndex
	 */
	public void moveSong(int selectedIndex, int newIndex) {
		if (autoSort) {
			throw new IllegalStateException("should move song at a specific position, but auto-sorting is enabled");
		} else {
			Song ret = songs.remove(selectedIndex);
			songs.add(newIndex, ret);
			notifyListModelListeners();
		}
	}
	
	public void removeSongsModelListener(SongsModelListener songsModelListener) {
		songsModelListeners.remove(songsModelListener);
	}
	
	public HashMap<String, Song> toMap() {
		HashMap<String, Song> map = new HashMap<>(songs.size());
		for (Song song : songs) {
			map.put(song.getUUID(), song);
		}
		return map;
	}
} public enum VirtualScreen {
	SCREEN_A("A"), SCREEN_B("B");
	
	private final String name;
	
	private VirtualScreen(String name) {
		this.name = name;
	}
	
	public String getName() {
		return name;
	}
	
	public Color getTextColor(SettingsModel settings) {
		return this == SCREEN_A
			? settings.get(SettingKey.TEXT_COLOR, Color.class)
			: settings.get(SettingKey.TEXT_COLOR_2, Color.class);
	}
	
	public Color getBackgroundColor(SettingsModel settings) {
		return this == SCREEN_A
			? settings.get(SettingKey.BACKGROUND_COLOR, Color.class)
			: settings.get(SettingKey.BACKGROUND_COLOR_2, Color.class);
	}
	
	public ScreenContentsEnum getScreenContents(SettingsModel settings) {
		return this == SCREEN_A
			? settings.get(SettingKey.SCREEN_1_CONTENTS, ScreenContentsEnum.class)
			: settings.get(SettingKey.SCREEN_2_CONTENTS, ScreenContentsEnum.class);
	}
	
	public Boolean getMinimalScrolling(SettingsModel settings) {
		return this == SCREEN_A
			? settings.get(SettingKey.MINIMAL_SCROLLING, Boolean.class)
			: settings.get(SettingKey.MINIMAL_SCROLLING_2, Boolean.class);
	}
	
	public Font getTitleFont(SettingsModel settings) {
		return this == SCREEN_A
			? settings.get(SettingKey.TITLE_FONT, Font.class)
			: settings.get(SettingKey.TITLE_FONT_2, Font.class);
	}
	
	public Font getLyricsFont(SettingsModel settings) {
		return this == SCREEN_A
			? settings.get(SettingKey.LYRICS_FONT, Font.class)
			: settings.get(SettingKey.LYRICS_FONT_2, Font.class);
	}
	
	public Font getChordSequenceFont(SettingsModel settings) {
		return this == SCREEN_A
			? settings.get(SettingKey.CHORD_SEQUENCE_FONT, Font.class)
			: settings.get(SettingKey.CHORD_SEQUENCE_FONT_2, Font.class);
	}
	
	public Font getTranslationFont(SettingsModel settings) {
		return this == SCREEN_A
			? settings.get(SettingKey.TRANSLATION_FONT, Font.class)
			: settings.get(SettingKey.TRANSLATION_FONT_2, Font.class);
	}
	
	public Font getCopyrightFont(SettingsModel settings) {
		return this == SCREEN_A
			? settings.get(SettingKey.COPYRIGHT_FONT, Font.class)
			: settings.get(SettingKey.COPYRIGHT_FONT_2, Font.class);
	}
} public enum SettingKey {
	/** should the song title be shown in presentation */
	SHOW_TITLE(Boolean.class),
	/** font of song title - first presentation screen */
	TITLE_FONT(Font.class),
	/** font of song lyrics (also used for chords if they are shown) - first presentation screen */
	LYRICS_FONT(Font.class),
	/** font of song lyrics translation - first presentation screen */
	TRANSLATION_FONT(Font.class),
	/** font of song copyright - first presentation screen */
	COPYRIGHT_FONT(Font.class),
	/** font of chord sequence block - first presentation screen */
	CHORD_SEQUENCE_FONT(Font.class),
	/** font of song title - second presentation screen */
	TITLE_FONT_2(Font.class),
	/** font of song lyrics (also used for chords if they are shown) - second presentation screen */
	LYRICS_FONT_2(Font.class),
	/** font of song lyrics translation - second presentation screen */
	TRANSLATION_FONT_2(Font.class),
	/** font of song copyright - second presentation screen */
	COPYRIGHT_FONT_2(Font.class),
	/** font of chord sequence block - second presentation screen */
	CHORD_SEQUENCE_FONT_2(Font.class),
	/** color of presentation text - first presentation screen */
	TEXT_COLOR(Color.class),
	/** color of presentation text - second presentation screen */
	TEXT_COLOR_2(Color.class),
	/** color of presentation background - first presentation screen */
	BACKGROUND_COLOR(Color.class),
	/** color of presentation background - second presentation screen */
	BACKGROUND_COLOR_2(Color.class),
	/** file to use as logo */
	LOGO_FILE(String.class),
	/** top margin for presentation */
	TOP_MARGIN(Integer.class),
	/** left margin for presentation */
	LEFT_MARGIN(Integer.class),
	/** right margin for presentation */
	RIGHT_MARGIN(Integer.class),
	/** bottom margin for presentation */
	BOTTOM_MARGIN(Integer.class),
	/** margin between title and lyrics */
	DISTANCE_TITLE_TEXT(Integer.class),
	/** margin between lyrics and copyright notice */
	DISTANCE_TEXT_COPYRIGHT(Integer.class),
	/** to which content the filter should be applied */
	SONG_LIST_FILTER(FilterTypeEnum.class),
	/** display of first configured presentation screen */
	SCREEN_1_DISPLAY(String.class),
	/** content of first configured presentation screen */
	SCREEN_1_CONTENTS(ScreenContentsEnum.class),
	/** display of second configured presentation screen */
	SCREEN_2_DISPLAY(String.class),
	/** content of second configured presentation screen */
	SCREEN_2_CONTENTS(ScreenContentsEnum.class),
	/** should the scrolling be minimized - first presentation screen */
	MINIMAL_SCROLLING(Boolean.class),
	/** should the scrolling be minimized - second presentation screen */
	MINIMAL_SCROLLING_2(Boolean.class),
	/** how long the song has to be shown until it counts as presented (for statistics) */
	SECONDS_UNTIL_COUNTED(Integer.class),
	/** directory to use for slide show */
	SLIDE_SHOW_DIRECTORY(String.class),
	/** how long each picture should be displayed in slide show */
	SLIDE_SHOW_SECONDS_UNTIL_NEXT_PICTURE(Integer.class),
	/** if remote connection should be used */
	REMOTE_ENABLED(Boolean.class),
	/** server uri for remote connection */
	REMOTE_SERVER(String.class),
	/** user-name for remote connection */
	REMOTE_USERNAME(String.class),
	/** password for remote connection */
	REMOTE_PASSWORD(String.class),
	/** prefix for remote connection */
	REMOTE_PREFIX(String.class),
	/** room for remote connection */
	REMOTE_NAMESPACE(String.class);
	
	private Class<?> type;
	
	private SettingKey(Class<?> type) {
		this.type = type;
	}
	
	public Class<?> getType() {
		return type;
	}
} @XmlRootElement(name = "settings")
@XmlAccessorType(XmlAccessType.NONE)
@XmlAccessorOrder(XmlAccessOrder.ALPHABETICAL)
public class SettingsModel implements Persistable {
	
	// TODO possible optimization: use a map to make read and write access faster (no for loop needed)
	@XmlElement(name = "setting")
	private SortedSet<Setting<Object>> store;
	
	public SettingsModel() {
		initIfNecessary();
	}
	
	@SuppressWarnings("unchecked")
	public <T> T get(SettingKey key, Class<T> clazz) {
		for (Setting<Object> setting : store) {
			if (setting.getKey() == key) {
				if (setting.getValue() != null && !clazz.isAssignableFrom(setting.getValue().getClass())) {
					throw new ClassCastException("wrong setting type - wanted " + clazz.getName() + " but found "
						+ setting.getValue().getClass().getName());
				}
				return (T) setting.getValue();
			}
		}
		return null;
	}
	
	private Object get(SettingKey key) {
		for (Setting<Object> setting : store) {
			if (setting.getKey() == key) {
				return setting.getValue();
			}
		}
		return null;
	}
	
	@Override
	public final void initIfNecessary() {
		if (store == null) {
			store = new TreeSet<>();
		}
		doMigrationIfNecessary();
	}
	
	private void doMigrationIfNecessary() {
		clearIfString(SettingKey.SCREEN_1_DISPLAY);
		clearIfString(SettingKey.SCREEN_2_DISPLAY);
	}
	
	private void clearIfString(SettingKey key) {
		if (get(key) instanceof String) {
			// it should be an Integer
			put(key, null);
		}
	}
	
	public boolean isSet(SettingKey key) {
		for (Setting<Object> setting : store) {
			if (setting.getKey() == key) {
				return true;
			}
		}
		return false;
	}
	
	public void put(SettingKey key, Object value) {
		Setting<Object> toSet = null;
		for (Setting<Object> setting : store) {
			if (setting.getKey() == key) {
				toSet = setting;
				break;
			}
		}
		if (toSet != null) {
			toSet.setValue(value);
		} else {
			store.add(new Setting<>(key, value));
		}
	}
	
} public class Presentable {
	
	final Song song;
	final String image;
	
	public Presentable(Song song, String image) {
		Preconditions.checkArgument(song == null || image == null, "you cant't display a song and a logo image simultaneously, it's either or!");
		
		this.song = song;
		this.image = image;
	}
	
	/**
	 * Get the song.
	 */
	public Song getSong() {
		return song;
	}
	
	/**
	 * Get the image filename.
	 */
	public String getImage() {
		return image;
	}
	
	@Override
	public int hashCode() {
		final int prime = 31;
		int result = 1;
		result = prime * result + ((image == null) ? 0 : image.hashCode());
		result = prime * result + ((song == null) ? 0 : song.hashCode());
		return result;
	}
	
	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		Presentable other = (Presentable) obj;
		if (image == null) {
			if (other.image != null)
				return false;
		} else if (!image.equals(other.image))
			return false;
		if (song == null) {
			if (other.song != null)
				return false;
		} else if (!song.equals(other.song))
			return false;
		return true;
	}
	
	@Override
	public String toString() {
		return song != null
			? song.getTitle()
			: (image != null
				? image
				: "blank");
	}
	
} public final class ScreenHelper {

	private static final ScreenChangeListener SCREEN_CHANGE_LISTENER = new ScreenChangeListener();
	
	private ScreenHelper() {
		// this class should not be instantiated
	}

	public static void addChangeListener(Runnable listener) {
		SCREEN_CHANGE_LISTENER.addListener(listener);
	}

	/**
	 * Get the comprehensive list of all screens attached to the system.
	 */
	public static List<SelectableDisplay> getScreens() {
		List<SelectableDisplay> result = new ArrayList<>();
		for (int i = 0; i < getGraphicsDevices().size(); i++) {
			result.add(new SelectableDisplay(i));
		}
		return result;
	}
	
	private static List<GraphicsDevice> getGraphicsDevices() {
		GraphicsEnvironment env = GraphicsEnvironment.getLocalGraphicsEnvironment();
		return Collections.unmodifiableList(Arrays.asList(env.getScreenDevices()));
	}
	
	/**
	 * Get a screen from the given list that has the specified index.
	 *
	 * @param screens
	 *            list of possible screens
	 * @param screenIndex
	 *            the index of the screen to find
	 */
	public static SelectableDisplay getScreen(List<SelectableDisplay> screens, Integer screenIndex) {
		if (screenIndex == null) {
			return null;
		}
		return getScreen(screens, screenIndex.intValue());
	}
	
	/**
	 * Get a screen from the given list that has the specified index.
	 *
	 * @param screens
	 *            list of possible screens
	 * @param screenIndex
	 *            the index of the screen to find
	 */
	public static SelectableDisplay getScreen(List<SelectableDisplay> screens, int screenIndex) {
		return screens.stream()
			.filter(scr -> scr != null && scr.getIndex() == screenIndex)
			.findFirst()
			.orElse(null);
	}
	
	public static GraphicsConfiguration getConfiguration(SelectableDisplay screen) {
		List<GraphicsDevice> graphicsDevices = getGraphicsDevices();
		return screen.getIndex() >= graphicsDevices.size()
			? null
			: graphicsDevices.get(screen.getIndex()).getDefaultConfiguration();
	}
	
} public interface UIScroller {
	
	/**
	 * Fetch a list of all part button groups.
	 */
	List<PartButtonGroup> getUIParts();
	
} public class PatchController extends SongsModelController {
	
	private static final Logger LOG = LoggerFactory.getLogger(PatchController.class);
	
	private static final String PREF_COMMENT = "RemoteController DB Properties";
	private static final String PREF_DB_PREFIX = "DB_PREFIX";
	private static final String PREF_DB_SERVER = "DB_SERVER";
	private static final String PREF_DB_UUID = "DB_UUID";
	private static final String PREF_SONGS_VERSION_ID = "SONGS_VERSION_ID";
	private final HashMap<String, Collection<Song>> patchMap = new HashMap<>();
	private final HashMap<Long, Version> patchVersions = new HashMap<>();
	private final HashSet<String> rejects = new HashSet<>();
	private long currentVersionId;
	private final RemoteController remoteController;
	private SongsModel db;
	private List<Song> offlineChanges;
	private OnChangeListener<SongsModel> onLatestChangeListener = (changes, args) -> addChanges(changes,
		(String) args[RemoteTopic.PATCHES_LATEST_CHANGES_ARG_UUID], true);
	private OnChangeListener<Version> onLatestVersionListener = (version, args) -> addPatchVersion(version, true);
	private OnChangeListener<ChangeReject> onLatestRejectListener = (reject, args) -> rejectPatch(reject.getUUID());
	private OnChangeListener<Patches> onRequestPatchesListener = (patches, args) -> addPatches(patches);
	private MqttObject.OnChangeListener<Version> onInitialLatestVersionListener = (version, args) -> requestMissingPatches();
	private boolean requestedMissingPatches;
	private String dbUUID = "";
	
	// Contains overwritten local changes:
	private LinkedList<Song> conflicts = new LinkedList<>();
	
	public PatchController(SongsModel songs, RemoteController remoteController) {
		super(songs);
		this.remoteController = remoteController;
		
		load();
		
		requestedMissingPatches = false;
		remoteController.getLatestChanges().onChange(onLatestChangeListener);
		remoteController.getLatestVersion().onChange(onLatestVersionListener);
		remoteController.getLatestReject().onChange(onLatestRejectListener);
		remoteController.getRequestPatches().onChange(onRequestPatchesListener);
		addPatchVersion(remoteController.getLatestVersion().get(), true);
	}
	
	/**
	 * Resets the database if prefix has changed, reads the current database version resets the
	 * requestMissingPatch boolean.
	 */
	private void load() {
		File dbFile = new File(FileAndDirectoryLocations.getDefaultDBFileName());
		File dbPropertiesFile = new File(FileAndDirectoryLocations.getDefaultDBPropertiesFileName());
		if (dbFile.exists() && !dbPropertiesFile.exists())
			dbFile.delete();
		if (!dbFile.exists() && dbPropertiesFile.exists())
			dbPropertiesFile.delete();
		
		// Load db & db properties:
		Properties properties = new Properties();
		if (!dbFile.exists()) {
			LOG.debug("not reading db from {} (file does not exist) but using empty model", dbFile.getAbsolutePath());
			db = new SongsModel();
		} else {
			LOG.debug("loading db from file {} and properties from file {}", dbFile.getAbsolutePath(), dbPropertiesFile.getAbsolutePath());
			try {
				db = XMLConverter.fromXMLToPersistable(new FileInputStream(dbFile));
				if (db == null)
					throw new IOException();
				properties.loadFromXML(new FileInputStream(dbPropertiesFile));
			} catch (IOException e) {
				e.printStackTrace();
				LOG.error("Could not read db from file {} and properties from file {}", dbFile.getAbsolutePath(), dbPropertiesFile.getAbsolutePath());
				db = new SongsModel();
				properties.clear();
			}
		}
		
		// Load db properties:
		currentVersionId = Long.parseLong(properties.getProperty(PREF_SONGS_VERSION_ID, "0"));
		dbUUID = properties.getProperty(PREF_DB_UUID, "");
		// Reset if prefix has changed:
		if (!remoteController.getPrefix().equals(properties.getProperty(PREF_DB_PREFIX, remoteController.getPrefix())) ||
			!remoteController.getServer().equals(properties.getProperty(PREF_DB_SERVER, remoteController.getServer())))
			resetDB("");
		LOG.debug("Loaded db for server " + remoteController.getServer() + "\\" + remoteController.getPrefix() + " and version: " + currentVersionId);
		
		// Collect offline changes, align songs with db, and rebase changes later:
		offlineChanges = collectChanges(songs);
		// Make sure our local songs are align with db:
		List<Song> copy = new ArrayList<>(db.getSize());
		for (Song song : db)
			copy.add(new Song(song));
		songs.update(new SongsModel(copy, false));
	}
	
	/**
	 * To reject patches.
	 *
	 * There is currently no reject handling for published patches by this client.
	 * As the server may allows fast forward merges. The user has to handle everything else.
	 *
	 * @param hash
	 */
	private void rejectPatch(String hash) {
		if (patchMap.containsKey(hash))
			patchMap.remove(hash);
		else
			rejects.add(hash);
	}
	
	/**
	 * Add patchversion to patchversion list.
	 *
	 * @param version
	 */
	private void addPatchVersion(Version version, boolean apply) {
		if (version == null)
			return;
		
		// First: Check if database changed
		if (!dbUUID.equals(version.getDBUUID())) {
			resetDB(version.getDBUUID());
		}
		
		// Then: Check if there is a new version
		if (version.getID() > currentVersionId) {
			patchVersions.put(version.getID(), version);
		}
		
		// Then apply patches:
		if (apply)
			applyPatches();
		
		// If this is the first time, request missing patches.
		if (!requestedMissingPatches)
			requestedMissingPatches = requestMissingPatches();
	}
	
	/**
	 * To request missing patches if, there are patches missing. This will not block.
	 *
	 * @return true, if request could made or no request was necessary
	 */
	public boolean requestMissingPatches() {
		if (remoteController == null)
			return false;
		
		MqttObject<Version> latestVersion = remoteController.getLatestVersion();
		if (latestVersion.get() == null) {
			return false;
		}
		
		if (latestVersion.get().getID() > currentVersionId)
			remoteController.getRequestGet().set(new PatchRequest(currentVersionId + 1));
		return true;
	}
	
	/**
	 * This will reset the local database to version 0.
	 * You have to request missing patches again to reset to the latest version.
	 */
	private void resetDB(String newDbUUID) {
		dbUUID = newDbUUID;
		patchVersions.clear();
		rejects.clear();
		patchMap.clear();
		conflicts.clear();
		offlineChanges = null;
		requestedMissingPatches = false;
		
		db.clear();
		songs.clear();
		currentVersionId = 0;
	}
	
	/**
	 * Add a new patch to the patchMap and apply if the version exists and if it's not rejected.
	 *
	 * @param changes
	 */
	private void addChanges(SongsModel changes, String uuid, boolean apply) {
		if (rejects.contains(uuid))
			rejects.remove(uuid);
		else
			patchMap.put(uuid, changes.getSongs());
		if (apply)
			applyPatches();
	}
	
	/**
	 * Add multiple patches in one call.
	 *
	 * @param patches
	 */
	private void addPatches(Patches patches) {
		for (Patch patch : patches.getPatches()) {
			addChanges(patch.getSongs(), patch.getVersion().getUUID(), false);
			addPatchVersion(patch.getVersion(), false);
		}
		applyPatches();
	}
	
	/**
	 * This will apply all recently received patches by updating the db and the main song list.
	 * It will run only if we got all patches up to the last one to not update the local database to frequently.
	 */
	private void applyPatches() {
		if (remoteController.getLatestVersion().get().getID() > currentVersionId) {
			for (Long i = currentVersionId + 1; i <= remoteController.getLatestVersion().get().getID(); i++) {
				if (!patchVersions.containsKey(i) || !patchMap.containsKey(patchVersions.get(i).getUUID())) {
					return;
				}
			}
			
			HashMap<String, Song> changedSongs = new HashMap<>();
			long versionAfterChangingSongs = currentVersionId;
			while (patchVersions.containsKey(versionAfterChangingSongs + 1) && patchMap.containsKey(patchVersions.get(versionAfterChangingSongs + 1)
				.getUUID())) {
				Version nextVersion = patchVersions.get(versionAfterChangingSongs + 1);
				Collection<Song> patch = patchMap.get(nextVersion.getUUID());
				for (Song patchSong : Objects.requireNonNull(patch)) {
					Song song = changedSongs.get(patchSong.getUUID());
					if (song == null)
						song = db.getByUUID(patchSong.getUUID());
					Song patchedSong = applyPatch(song, patchSong);
					changedSongs.put(patchSong.getUUID(), patchedSong);
				}
				versionAfterChangingSongs = nextVersion.getID();
			}
			if (!changedSongs.isEmpty()) {
				db.updateSongsByUUID(changedSongs.values());
				currentVersionId = versionAfterChangingSongs;
				
				identifyConflicts(changedSongs);
				
				super.updateSongs(changedSongs.values());
			}
		}
		
		// If we have offline changes, publish them:
		if (offlineChanges != null &&
			remoteController.getLatestVersion().get().getID() == currentVersionId
			&& remoteController.getHealthDB().get() == Health.online) {
			changeSongs(offlineChanges);
			offlineChanges = null;
		}
	}
	
	/**
	 * To identify conflicts of pulled changes with local offline changes.
	 *
	 * @param changedSongs
	 */
	private void identifyConflicts(HashMap<String, Song> changedSongs) {
		// Identify conflicts, remove them from offline changes
		if (offlineChanges != null && !offlineChanges.isEmpty()) {
			LinkedList<Song> conflictChanges = new LinkedList<>();
			for (Song remoteChange : changedSongs.values()) {
				for (Song offlineChange : offlineChanges) {
					if (remoteChange.getUUID().equals(offlineChange.getUUID())) {
						conflictChanges.add(offlineChange);
					}
				}
			}
			offlineChanges.removeAll(conflictChanges);
			conflicts.addAll(conflictChanges);
		}
	}
	
	/**
	 * To collect differences with the db and also update the given songs to match with the db.
	 *
	 * @param songsToCheck
	 *            Empty songs are handled as not existing songs.
	 */
	private List<Song> collectChanges(SongsModel songsToCheck) {
		List<Song> changes = new LinkedList<>();
		Map<String, Song> dbMap = db.toMap();
		Map<String, Song> songsMap = songsToCheck.toMap();
		// If song has been changed in songs:
		for (Map.Entry<String, Song> songsEntry : songsMap.entrySet()) {
			if (songsEntry.getValue().isEmpty())
				continue;
			if (dbMap.containsKey(songsEntry.getKey())) {
				Song dbSong = dbMap.get(songsEntry.getKey());
				if (!songsEntry.getValue().equals(dbSong)) {
					changes.add(songsEntry.getValue());
				}
			} else {
				// New Song in songs:
				changes.add(songsEntry.getValue());
			}
		}
		// If song has been removed in songs, add an empty one to changes:
		for (String key : dbMap.keySet()) {
			if (!songsMap.containsKey(key) || songsMap.get(key).isEmpty()) {
				changes.add(new Song(key));
			}
		}
		return changes;
	}
	
	/**
	 * To add, change or delete a songs in the synchronized database.
	 *
	 * @param newSongs
	 */
	private void changeSongs(Iterable<Song> newSongs) {
		LinkedList<Song> patchSongs = new LinkedList<>();
		for (Song newSong : newSongs) {
			Song songFromDB = db.getByUUID(newSong.getUUID());
			if (songFromDB == null)
				songFromDB = new Song(newSong.getUUID());
			Song patchSong = patch(newSong, songFromDB);
			if (!patchSong.isEmpty())
				patchSongs.add(patchSong);
		}
		
		if (patchSongs.size() > 0) {
			long newVersionId = currentVersionId + 1;
			String uuid = UUID.randomUUID().toString();
			remoteController.getLatestChanges().set(new SongsModel(patchSongs, false),
				remoteController.getUsername(), String.valueOf(newVersionId), uuid);
		}
	}
	
	public static Song patch(Song song, Song baseSong) {
		final DiffMatchPatch dmp = new DiffMatchPatch();
		
		Map<String, String> songEntries = baseSong.toMap();
		HashMap<String, String> patchSongMap = new HashMap<>();
		for (Map.Entry<String, String> newSongEntry : song.toMap().entrySet()) {
			if (newSongEntry.getKey().equals("uuid"))
				continue;
			String textNew = StringTools.nullAsEmptyString(newSongEntry.getValue());
			String textOld = StringTools.nullAsEmptyString(songEntries.get(newSongEntry.getKey()));
			String patchesText = dmp.patchToText(dmp.patchMake(textOld, textNew));
			if (!patchesText.isEmpty())
				patchSongMap.put(newSongEntry.getKey(), patchesText);
		}
		Song patchSong = new Song(song.getUUID());
		patchSong.fromMap(patchSongMap);
		return patchSong;
	}
	
	public static Song applyPatch(Song song, Song patch) {
		final DiffMatchPatch dmp = new DiffMatchPatch();
		
		Map<String, String> songEntries = song == null ? new Song(patch.getUUID()).toMap() : song.toMap();
		for (Map.Entry<String, String> patchEntry : patch.toMap().entrySet()) {
			if (patchEntry.getKey().equals("uuid") || StringTools.isEmpty(patchEntry.getValue()))
				continue;
			
			String previous = songEntries.containsKey(patchEntry.getKey()) && songEntries.get(patchEntry.getKey()) != null ? songEntries
				.get(patchEntry.getKey()) : "";
			LinkedList<DiffMatchPatch.Patch> patchesForEntry = (LinkedList<DiffMatchPatch.Patch>) dmp.patchFromText(patchEntry.getValue());
			
			Object[] result = dmp.patchApply(patchesForEntry, previous);
			if (((String) result[0]).isEmpty())
				songEntries.remove(patchEntry.getKey());
			else
				songEntries.put(patchEntry.getKey(), (String) result[0]);
		}
		return new Song(songEntries);
	}
	
	public static Song mergePatches(Song patchOne, Song patchTwo) {
		final DiffMatchPatch dmp = new DiffMatchPatch();
		
		Map<String, LinkedHashSet<String>> patches = new HashMap<>();
		for (Map.Entry<String, String> patchEntry : patchOne.toMap().entrySet()) {
			if (patchEntry.getKey().equals("uuid") || StringTools.isEmpty(patchEntry.getValue()))
				continue;
			
			patches.put(patchEntry.getKey(), dmp.patchFromText(patchEntry.getValue()).stream()
				.map(DiffMatchPatch.Patch::toString).collect(Collectors.toCollection(LinkedHashSet::new)));
		}
		
		for (Map.Entry<String, String> patchEntry : patchTwo.toMap().entrySet()) {
			if (patchEntry.getKey().equals("uuid") || StringTools.isEmpty(patchEntry.getValue()))
				continue;
			
			LinkedHashSet<String> patchesTwo = dmp.patchFromText(patchEntry.getValue()).stream()
				.map(DiffMatchPatch.Patch::toString).collect(Collectors.toCollection(LinkedHashSet::new));
			if (!patches.containsKey(patchEntry.getKey())) {
				patches.put(patchEntry.getKey(), patchesTwo);
				continue;
			}
			
			patches.get(patchEntry.getKey()).addAll(patchesTwo);
		}
		
		Map<String, String> songEntries = new Song(patchOne.getUUID()).toMap();
		for (Map.Entry<String, LinkedHashSet<String>> patchEntry : patches.entrySet()) {
			StringBuilder text = new StringBuilder();
			for (String aPatch : patchEntry.getValue()) {
				text.append(aPatch);
			}
			songEntries.put(patchEntry.getKey(), text.toString());
		}
		return new Song(songEntries);
	}
	
	/**
	 * Save the synchronized database at the current version.
	 */
	@Override
	public boolean save() {
		super.save();
		
		File dbPropertiesFile = new File(FileAndDirectoryLocations.getDefaultDBPropertiesFileName());
		File dbFile = new File(FileAndDirectoryLocations.getDefaultDBFileName());
		
		try (OutputStream xmlOutputStream = new FileOutputStream(dbFile)) {
			LOG.debug("writing db to file \"{}\" and db properties to file \"{}\"", dbFile.getAbsolutePath(), dbPropertiesFile.getAbsolutePath());
			XMLConverter.fromPersistableToXML(db, xmlOutputStream);
			
			Properties properties = new Properties();
			properties.setProperty(PREF_DB_PREFIX, remoteController.getPrefix());
			properties.setProperty(PREF_DB_SERVER, remoteController.getServer());
			properties.setProperty(PREF_SONGS_VERSION_ID, String.valueOf(currentVersionId));
			properties.setProperty(PREF_DB_UUID, dbUUID);
			properties.storeToXML(new FileOutputStream(dbPropertiesFile), PREF_COMMENT);
			
			LOG.debug("Saved db to with version " + currentVersionId);
			return true;
		} catch (IOException e) {
			LOG.error("could not write db", e);
			return false;
		}
	}
	
	/**
	 * @see org.zephyrsoft.sdb2.model.SongsModelController#close()
	 */
	@Override
	public boolean close() {
		remoteController.getLatestChanges().removeOnChangeListener(onLatestChangeListener);
		remoteController.getLatestVersion().removeOnChangeListener(onLatestVersionListener);
		remoteController.getLatestReject().removeOnChangeListener(onLatestRejectListener);
		remoteController.getRequestPatches().removeOnChangeListener(onRequestPatchesListener);
		remoteController.getLatestVersion().removeOnChangeListener(onInitialLatestVersionListener);
		return true;
	}
	
	@Override
	public void update(SongsModel songsForUpdate) {
		changeSongs(collectChanges(songsForUpdate));
	}
	
	@Override
	public boolean updateSongs(Iterable<Song> changedSongs) {
		changeSongs(changedSongs);
		return true;
	}
	
	@Override
	public boolean removeSong(Song songToDelete) {
		changeSongs(Collections.singletonList(new Song(songToDelete.getUUID())));
		return true;
	}
	
} public enum RemoteStatus {
	OFF,
	CONNECTING,
	CONNECTED,
	DISCONNECTING,
	FAILURE,
	DB_DISCONNECTED;
} public class ExportService {
	
	private class PageNumbers implements IEventHandler {
		@Override
		public void handleEvent(Event event) {
			PdfDocumentEvent docEvent = (PdfDocumentEvent) event;
			PdfPage page = docEvent.getPage();
			int pageNum = docEvent.getDocument().getPageNumber(page);
			PdfCanvas canvas = new PdfCanvas(page);
			canvas.beginText();
			canvas.setFontAndSize(baseFont, 10);
			Rectangle pageSize = page.getPageSizeWithRotation();
			canvas.moveText(pageSize.getWidth() / 2 - 10, 15);
			canvas.showText(String.format("- %d -", pageNum));
			canvas.endText();
			canvas.stroke();
			canvas.release();
		}
	}
	
	private class ExportInProgress {
		private final ExportFormat exportFormat;
		private final Document document;
		private final List<TocEntry> toc = new ArrayList<>();
		/** only used in song body (not for title or copyright) */
		private Paragraph currentLine;
		
		public ExportInProgress(ExportFormat exportFormat, Document document) {
			this.exportFormat = exportFormat;
			this.document = document;
		}
		
		public ExportFormat getExportFormat() {
			return exportFormat;
		}
		
		public Document getDocument() {
			return document;
		}
		
		public List<TocEntry> getToc() {
			return toc;
		}
		
		public Paragraph getCurrentLine() {
			return currentLine;
		}
		
		public Paragraph getOrCreateCurrentLine(Supplier<Paragraph> paragraphSupplier) {
			if (currentLine == null) {
				currentLine = paragraphSupplier.get();
			}
			return currentLine;
		}
		
		public void setCurrentLine(Paragraph currentLine) {
			this.currentLine = currentLine;
		}
	}
	
	@FunctionalInterface
	private interface SongElementHandler {
		void handleElement(ExportInProgress exportInProgress, Song song, SongElementHistory history);
	}
	
	@FunctionalInterface
	private interface AttributeSetter {
		void apply(AbstractElement<?> element);
	}
	
	private AttributeSetter titleAttributes;
	private AttributeSetter lyricsAttributes;
	private AttributeSetter translationAttributes;
	private AttributeSetter copyrightAttributes;
	private ChordSpaceCorrector chordSpaceCorrector;
	private Map<SongElementEnum, SongElementHandler> songElementHandlers;
	private PdfFont baseFont;
	
	private void init() throws Exception {
		baseFont = PdfFontFactory.createFont(StandardFonts.TIMES_ROMAN, PdfEncodings.WINANSI, EmbeddingStrategy.PREFER_EMBEDDED);
		PdfFont boldFont = PdfFontFactory.createFont(StandardFonts.TIMES_BOLD, PdfEncodings.WINANSI, EmbeddingStrategy.PREFER_EMBEDDED);
		PdfFont italicFont = PdfFontFactory.createFont(StandardFonts.TIMES_ITALIC, PdfEncodings.WINANSI, EmbeddingStrategy.PREFER_EMBEDDED);
		titleAttributes = e -> {
			e.setFont(boldFont);
			e.setFontSize(20);
		};
		lyricsAttributes = e -> {
			e.setFont(baseFont);
			e.setFontSize(12);
		};
		translationAttributes = e -> {
			e.setFont(italicFont);
			e.setFontSize(8);
		};
		copyrightAttributes = e -> {
			e.setFont(italicFont);
			e.setFontSize(10);
		};
		
		chordSpaceCorrector = new ChordSpaceCorrector(
			text -> (int) baseFont.getWidth(text, 12));
		
		songElementHandlers = buildSongElementHandlerMap();
	}
	
	private Map<SongElementEnum, SongElementHandler> buildSongElementHandlerMap() {
		Map<SongElementEnum, SongElementHandler> handlers = new EnumMap<>(SongElementEnum.class);
		handlers.put(TITLE, titleHandler());
		handlers.put(LYRICS, lyricsHandler());
		handlers.put(TRANSLATION, translationHandler());
		handlers.put(NEW_LINE, newlineHandler());
		handlers.put(COPYRIGHT, copyrightHandler());
		// CHORDS don't have a handler, they are handled by the following LYRICS element
		return handlers;
	}
	
	private SongElementHandler titleHandler() {
		return (exportInProgress, song, history) -> {
			Text chunk = new Text(song.getTitle() + "\n");
			exportInProgress.getToc().add(new TocEntry(song.getUUID(), song.getTitle(),
				exportInProgress.getDocument().getPdfDocument().getNumberOfPages()));
			Paragraph paragraph = paragraph(titleAttributes);
			paragraph.setDestination(song.getUUID());
			paragraph.setMarginBottom(15);
			paragraph.add(chunk);
			exportInProgress.getDocument().add(paragraph);
			
			if (exportInProgress.getExportFormat().areChordsShown() && StringUtils.isNotBlank(song.getCleanChordSequence())) {
				// insert chord sequence directly after title
				Paragraph chordSequence = paragraph(song.getCleanChordSequence() + "\n\n", lyricsAttributes);
				chordSequence.setPaddingLeft(30);
				exportInProgress.getDocument().add(chordSequence);
			}
		};
	}
	
	private SongElementHandler lyricsHandler() {
		return (exportInProgress, song, history) -> {
			String chordsLine = "";
			SongElementHistoryQueryResult queryResult = history.query()
				.without(NEW_LINE)
				.lastSeen(is(CHORDS))
				.end();
			
			if (queryResult.isMatched() && exportInProgress.getExportFormat().areChordsShown()) {
				chordsLine = chordSpaceCorrector.correctChordSpaces(queryResult.getMatchedElements().get(0).getContent(),
					history.current().getContent()) + "\n";
			}
			Paragraph currentLine = exportInProgress.getOrCreateCurrentLine(this::paragraph);
			if (history.current().getIndentation() > 0) {
				currentLine.setPaddingLeft(calculateIndentation(history.current()));
			}
			currentLine.add(text(chordsLine + history.current().getContent(), lyricsAttributes));
		};
	}
	
	private SongElementHandler translationHandler() {
		return (exportInProgress, song, history) -> {
			if (exportInProgress.getExportFormat().isTranslationShown()) {
				Paragraph currentLine = exportInProgress.getOrCreateCurrentLine(this::paragraph);
				if (history.current().getIndentation() > 0) {
					currentLine.setPaddingLeft(calculateIndentation(history.current()));
				}
				currentLine.add(text(history.current().getContent(), translationAttributes));
			}
		};
	}
	
	private Text text(String text, AttributeSetter attributes) {
		Text result = new Text(text);
		attributes.apply(result);
		result.setNextRenderer(new TextRendererNonTrimming(result));
		return result;
	}
	
	private SongElementHandler newlineHandler() {
		return (exportInProgress, song, history) -> {
			if (exportInProgress.getCurrentLine() != null) {
				if (history.query()
					.lastSeen(is(NEW_LINE))
					.end().isMatched()) {
					// two empty paragraphs won't render as an empty line (iText-specific behaviour),
					// so we have to add a newline to the existing paragraph instead
					exportInProgress.getCurrentLine().add("\n");
				}
				exportInProgress.getDocument().add(exportInProgress.getCurrentLine());
			}
			exportInProgress.setCurrentLine(paragraph());
		};
	}
	
	private SongElementHandler copyrightHandler() {
		return (exportInProgress, song, history) -> {
			Paragraph copyright = paragraph(history.current().getContent(), copyrightAttributes);
			SongElementHistoryQueryResult queryResult = history.query()
				.without(NEW_LINE)
				.lastSeen(is(COPYRIGHT))
				.end();
			if (!queryResult.isMatched()) {
				copyright.setPaddingTop(20);
			}
			exportInProgress.getDocument().add(copyright);
		};
	}
	
	private int calculateIndentation(SongElement element) {
		return element.getIndentation() * 12;
	}
	
	public byte[] export(ExportFormat exportFormat, Collection<Song> songs) throws Exception {
		init();
		
		ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
		
		try {
			Document document = beginDocument(outputStream);
			
			ExportInProgress exportInProgress = new ExportInProgress(exportFormat, document);
			for (Song song : songs) {
				List<SongElement> songElements = SongParser.parse(song, true, true, true);
				
				if (exportFormat.onlySongsWithChords()
					&& songElements.stream().noneMatch(e -> e.getType() == CHORDS)) {
					continue;
				}
				
				SongElementHistory history = new SongElementHistory(songElements);
				for (SongElement songElement : history) {
					SongElementHandler handler = songElementHandlers.get(songElement.getType());
					if (handler != null) {
						handler.handleElement(exportInProgress, song, history);
					}
				}
				
				document.add(new AreaBreak(AreaBreakType.NEXT_PAGE));
			}
			
			appendTableOfContents(document, exportInProgress.getToc());
			
			document.close();
		} catch (Exception e) {
			throw new RuntimeException("error while creating PDF document", e);
		}
		
		return outputStream.toByteArray();
	}
	
	private static record TocEntry(String technicalName, String title, Integer pageNumber) {
		// nothing here
	}
	
	private Document beginDocument(ByteArrayOutputStream outputStream) {
		PdfDocument pdf = new PdfDocument(new PdfWriter(outputStream));
		pdf.addEventHandler(PdfDocumentEvent.START_PAGE, new PageNumbers());
		Document doc = new Document(pdf);
		doc.setMargins(50, 50, 30, 30);
		return doc;
	}
	
	private void appendTableOfContents(Document document, List<TocEntry> toc) {
		document.add(paragraph("Table of Contents", titleAttributes));
		for (TocEntry tocEntry : toc) {
			String songTitle = tocEntry.title();
			String technicalName = tocEntry.technicalName();
			Integer pageNumber = tocEntry.pageNumber();
			
			Paragraph p = paragraph(lyricsAttributes);
			p.addTabStops(List.of(new TabStop(580, TabAlignment.RIGHT, new DottedLine())))
				.add(songTitle)
				.add(new Tab())
				.add(String.valueOf(pageNumber))
				.setAction(PdfAction.createGoTo(technicalName));
			document.add(p);
		}
	}
	
	private Paragraph paragraph() {
		Paragraph paragraph = new Paragraph();
		paragraph.setMultipliedLeading(1);
		paragraph.setMargin(0);
		paragraph.setPadding(0);
		return paragraph;
	}
	
	private Paragraph paragraph(AttributeSetter attributes) {
		Paragraph paragraph = paragraph();
		attributes.apply(paragraph);
		return paragraph;
	}
	
	private Paragraph paragraph(String text, AttributeSetter attributes) {
		Paragraph paragraph = paragraph(attributes);
		paragraph.add(text);
		return paragraph;
	}
	
} public enum FieldName {
	
	UUID, TITLE, LYRICS;

} public enum IndexType {
	
	ALL_SONGS, PRESENTED_SONGS;

} public class IndexerService {
	
	private static final Logger LOG = LoggerFactory.getLogger(IndexerService.class);
	
	private static final String SIMPLIFY_INDEXING_REGEX = "[-\r\n\t.,;:_/+'\"!?()\\[\\]]++";
	public static final String TERM_SPLIT_REGEX = "[- .,;:_/+'\"!?()\\[\\]]++";
	
	private Map<String, Song> songByUuid = new HashMap<>();
	
	private Map<IndexType, Directory> indexes = new HashMap<>();
	private static final Object INDEXES_LOCK = new Object();
	
	private final ExecutorService executor = Executors.newSingleThreadExecutor();
	
	private final ArrayList<OnIndexChangeListener> onIndexChangeListeners = new ArrayList<>();
	
	private Directory getIndex(IndexType indexType) {
		synchronized (INDEXES_LOCK) {
			return indexes.get(indexType);
		}
	}
	
	private void putIndex(IndexType indexType, Directory directory) {
		synchronized (INDEXES_LOCK) {
			indexes.put(indexType, directory);
		}
	}
	
	public void index(final IndexType indexType, final Collection<Song> songs) {
		executor.execute(() -> {
			Stopwatch stopwatch = Stopwatch.createStarted();
			
			Directory directory = new ByteBuffersDirectory();
			try {
				Analyzer analyzer = CustomAnalyzer.builder()
					.withTokenizer(StandardTokenizerFactory.class)
					.addTokenFilter(LowerCaseFilterFactory.class)
					.addTokenFilter(NGramFilterFactory.class, "minGramSize", "1", "maxGramSize", "25")
					.build();
				
				IndexWriterConfig config = new IndexWriterConfig(analyzer);
				try (IndexWriter writer = new IndexWriter(directory, config)) {
					for (Song song : songs) {
						Document document = createDocument(song);
						writer.addDocument(document);
						songByUuid.put(song.getUUID(), song);
					}
				} catch (IOException e) {
					LOG.warn("couldn't index songs", e);
				}
			} catch (IOException e1) {
				LOG.warn("couldn't create analyzer", e1);
			} finally {
				putIndex(indexType, directory);
				stopwatch.stop();
				LOG.info("indexing songs in background thread took {}", stopwatch.toString());
				onIndexChangeListeners.forEach((l) -> l.onIndexChange());
			}
		});
	}
	
	private Document createDocument(Song song) {
		Document document = new Document();
		document.add(new StringField(FieldName.UUID.name(), song.getUUID(), Store.YES));
		document.add(new TextField(FieldName.TITLE.name(), simplify(song.getTitle()), Store.NO));
		document.add(new TextField(FieldName.LYRICS.name(), simplify(song.getLyrics()), Store.NO));
		return document;
	}
	
	private String simplify(String content) {
		return content == null ? "" : content.replaceAll(SIMPLIFY_INDEXING_REGEX, " ");
	}
	
	public List<Song> search(IndexType indexType, String searchString, FieldName... fieldsToSearchIn) {
		try {
			Directory directory = getIndex(indexType);
			IndexReader indexReader = DirectoryReader.open(directory);
			IndexSearcher indexSearcher = new IndexSearcher(indexReader);
			
			BooleanQuery.Builder outerBooleanQueryBuilder = new BooleanQuery.Builder();
			for (FieldName field : fieldsToSearchIn) {
				PhraseQuery.Builder phraseQueryBuilder = new PhraseQuery.Builder();
				for (String searchTerm : searchString.toLowerCase().split(TERM_SPLIT_REGEX)) {
					phraseQueryBuilder.add(new Term(field.name(), searchTerm));
				}
				PhraseQuery phraseQuery = phraseQueryBuilder.build();
				outerBooleanQueryBuilder.add(phraseQuery, Occur.SHOULD);
			}
			BooleanQuery outerBooleanQuery = outerBooleanQueryBuilder.build();
			TopDocs hits = indexSearcher.search(outerBooleanQuery, Integer.MAX_VALUE);
			
			LOG.trace("{} hits for filter \"{}\"", hits.totalHits, outerBooleanQueryBuilder);
			
			List<Song> ret = new ArrayList<>();
			for (ScoreDoc scoreDocument : hits.scoreDocs) {
				Document document;
				document = indexSearcher.doc(scoreDocument.doc);
				String uuid = document.get(FieldName.UUID.name());
				ret.add(songByUuid.get(uuid));
			}
			return ret;
		} catch (Exception e) {
			LOG.warn("problem while searching", e);
			return new ArrayList<>(0);
		}
	}
	
	public void onIndexChange(OnIndexChangeListener onIndexChangeListener) {
		onIndexChangeListeners.add(onIndexChangeListener);
	}
	
	public void removeOnIndexChange(OnIndexChangeListener onIndexChangeListener) {
		onIndexChangeListeners.remove(onIndexChangeListener);
	}
	
	public interface OnIndexChangeListener {
		void onIndexChange();
	}
	
} public interface OnIndexChangeListener {
	void onIndexChange();
} public class CustomFileFilter extends javax.swing.filechooser.FileFilter implements java.io.FileFilter {
	
	private String[] extension = new String[] {};
	private String description = "";
	
	public CustomFileFilter(String description, String... extension) {
		this.extension = extension;
		this.description = description;
	}
	
	@Override
	public boolean accept(File f) {
		if (f.isDirectory()) {
			return true;
		}
		for (int i = 0; i < extension.length; i++) {
			if (f.getName().toLowerCase().endsWith(extension[i])) {
				return true;
			}
		}
		return false;
	}
	
	@Override
	public String getDescription() {
		return description;
	}
} public final class ResourceTools {
	
	private ResourceTools() {
		// no instantiation allowed
	}
	
	/**
	 * Load an input stream from a resource.
	 */
	public static InputStream getInputStream(Class<?> classToUse, String resourcePath) {
		String path = cleanPath(resourcePath);
		InputStream ret = classToUse.getResourceAsStream(path);
		
		if (ret == null) {
			// try again using a "root" slash
			ret = classToUse.getResourceAsStream("/" + path);
		}
		return ret;
	}
	
	/**
	 * Load an icon from a resource.
	 */
	public static Icon getIcon(Class<?> classToUse, String resourcePath) {
		String path = cleanPath(resourcePath);
		URL url = classToUse.getResource(path);
		if (url == null) {
			// try again using a "root" slash
			url = classToUse.getResource("/" + path);
		}
		return new ImageIcon(url);
	}
	
	/**
	 * Load an image from a resource.
	 */
	public static Image getImage(Class<?> classToUse, String resourcePath) {
		String path = cleanPath(resourcePath);
		URL url = classToUse.getResource(path);
		if (url == null) {
			// try again using a "root" slash
			url = classToUse.getResource("/" + path);
		}
		return Toolkit.getDefaultToolkit().getImage(url);
	}
	
	private static String cleanPath(String pathToClean) {
		String path = pathToClean;
		if (path.startsWith("/")) {
			path = path.substring(1);
		}
		return path;
	}
	
} public final class StringTools {
	
	private StringTools() {
		// this class is not intended for instantiation
	}
	
	/**
	 * Create a random UUID value.
	 */
	public static String createUUID() {
		return UUID.randomUUID().toString();
	}
	
	/**
	 * Compare two Strings with special care. If one of the Strings is {@code null}, it is assumed "smaller" than the
	 * non-null String.
	 *
	 * @see String#compareTo(String)
	 * @param one
	 *            first parameter to compare
	 * @param two
	 *            second parameter to compare
	 * @return the same scheme as in {@link String#compareTo(String)}
	 */
	public static int compareWithNullFirst(String one, String two) {
		if (one == null && two != null) {
			return -1;
		} else if (one != null && two == null) {
			return 1;
		} else if (one == null && two == null) {
			return 0;
		} else if (one != null && two != null) {
			return one.compareTo(two);
		} else {
			// to silence the Eclipse warning, will never be executed:
			throw new IllegalStateException();
		}
	}
	
	/**
	 * Compare two Strings with regards to the current default locale. If one of the Strings is {@code null}, it is
	 * assumed "smaller" than the non-null String.
	 *
	 * @see String#compareTo(String)
	 * @param one
	 *            first parameter to compare
	 * @param two
	 *            second parameter to compare
	 * @return the same scheme as in {@link String#compareTo(String)}
	 */
	public static int compareLocaleBasedWithNullFirst(String one, String two) {
		if (one == null && two != null) {
			return -1;
		} else if (one != null && two == null) {
			return 1;
		} else if (one == null && two == null) {
			return 0;
		} else if (one != null && two != null) {
			Collator collator = Collator.getInstance();
			return collator.compare(one, two);
		} else {
			// to silence the Eclipse warning, will never be executed:
			throw new IllegalStateException();
		}
	}
	
	/**
	 * Equals method capable of handling null values.
	 *
	 * @param one
	 *            first string
	 * @param two
	 *            second string
	 * @return {@code true} if both strings are {@code null} or if they are equal via {@link String#equals(Object)}
	 *         method, {@code false} else
	 */
	public static boolean equals(String one, String two) {
		return (one == null && two == null) || (one != null && one.equals(two));
	}
	
	/**
	 * Equals method capable of handling null values. {@code null} values are treated as equal to empty strings.
	 *
	 * @param one
	 *            first string
	 * @param two
	 *            second string
	 * @return {@code true} if both strings are ({@code null} or an empty string) or if they are equal via
	 *         {@link String#equals(Object)} method, {@code false} else
	 */
	public static boolean equalsWithNullAsEmpty(String one, String two) {
		return (StringUtils.isEmpty(one) && StringUtils.isEmpty(two)) || (one != null && one.equals(two));
	}
	
	/**
	 * Tests if a string is null or empty.
	 *
	 * @param toTest
	 *            the string to test
	 * @return {@code true} if the string is null or empty, {@code false} else
	 */
	public static boolean isEmpty(String toTest) {
		return toTest == null || toTest.isEmpty();
	}
	
	/**
	 * Returns exactly the argument, unless the argument is {@code null}. In this case, this method returns the empty
	 * string.
	 *
	 * @param in
	 *            the string to check
	 * @return a non-null value
	 */
	public static String nullAsEmptyString(String in) {
		if (in == null) {
			return "";
		} else {
			return in;
		}
	}
	
	/**
	 * Tests is a string is null, empty or only contains whitespace.
	 *
	 * @param in
	 *            the string to test
	 * @return {@code true} if the string is null, empty or only contains whitespace; {@code false} else
	 */
	public static boolean isBlank(String in) {
		return isEmpty(in) || in.trim().length() == 0;
	}
}
 public class VersionTools {
	
	private static final Logger LOG = LoggerFactory.getLogger(VersionTools.class);
	
	private VersionTools() {
		// only static use
	}
	
	public static String getCurrent() {
		String version = getImplementationVersion();
		LocalDateTime timestamp = getTimestampAsLocalDateTime();
		if (version != null && timestamp != null) {
			version += " (" + DateTools.formatDateTime(timestamp) + ")";
		} else {
			version = "development snapshot";
		}
		return version;
	}
	
	private static String getImplementationVersion() {
		return JarTools.getAttributeFromManifest(VersionTools.class, "Implementation-Version");
	}
	
	private static String getTimestamp() {
		return JarTools.getAttributeFromManifest(VersionTools.class, "Build-Timestamp");
	}
	
	private static LocalDateTime getTimestampAsLocalDateTime() {
		String timestamp = getTimestamp();
		return DateTools.parseDateTime(timestamp);
	}
	
	/**
	 * @return the latest version update or {@code null} if the running version is the latest one
	 */
	public static VersionUpdate getLatest() {
		try {
			HttpRequest request = HttpRequest.newBuilder()
				.uri(new URI("https://api.github.com/repos/mathisdt/sdb2/releases/latest"))
				.GET()
				.build();
			HttpResponse<String> latestReleaseResponse = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
			
			if (latestReleaseResponse == null) {
				return null;
			}
			
			JsonObject latestRelease = JsonParser.parseString(latestReleaseResponse.body()).getAsJsonObject();
			
			LocalDateTime latestReleaseTimestamp = DateTools.parseDateTime(latestRelease.get("published_at").getAsString());
			LocalDateTime ownTimestamp = getTimestampAsLocalDateTime();
			
			LOG.info("own timestamp: {} / latest release timestamp: {}", ownTimestamp, latestReleaseTimestamp);
			if (ownTimestamp == null || latestReleaseTimestamp == null
				|| DateTools.isMax15MinutesLater(latestReleaseTimestamp, ownTimestamp)) {
				// is already latest release or we cannot tell
				return null;
			} else {
				return new VersionUpdate(DateTools.formatDateTime(latestReleaseTimestamp),
					latestRelease.get("html_url").getAsString());
			}
		} catch (Exception e) {
			LOG.warn("error communicating with GitHub", e);
			return null;
		}
	}
	
	public static class VersionUpdate {
		private final String versionTimestamp;
		private final String webUrl;
		
		public VersionUpdate(String versionTimestamp, String webUrl) {
			this.versionTimestamp = versionTimestamp;
			this.webUrl = webUrl;
		}
		
		public String getVersionTimestamp() {
			return versionTimestamp;
		}
		
		public String getWebUrl() {
			return webUrl;
		}
	}
	
} public static class VersionUpdate {
	private final String versionTimestamp;
	private final String webUrl;
		
	public VersionUpdate(String versionTimestamp, String webUrl) {
		this.versionTimestamp = versionTimestamp;
		this.webUrl = webUrl;
	}
		
	public String getVersionTimestamp() {
		return versionTimestamp;
	}
		
	public String getWebUrl() {
		return webUrl;
	}
} public class ErrorDialog extends JDialog {
	
	private static final long serialVersionUID = 2294895894050002050L;
	
	private final JPanel contentPanel = new JPanel();
	private JTextArea txtrAaa;
	
	public ErrorDialog(Component parent) {
		setIconImages(MainWindow.getIconsFromResources(getClass()));
		setTitle("Error");
		setSize(400, 250);
		if (parent != null) {
			setLocationRelativeTo(parent);
		} else {
			Rectangle defaultScreenRectangle = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice().getDefaultConfiguration()
				.getBounds();
			setLocation((int) (defaultScreenRectangle.getWidth() / 2 - getSize().getWidth() / 2),
				(int) (defaultScreenRectangle.getHeight() / 2 - getSize().getHeight() / 2));
		}
		getContentPane().setLayout(new BorderLayout());
		contentPanel.setLayout(new BorderLayout());
		contentPanel.setBorder(null);
		getContentPane().add(contentPanel, BorderLayout.CENTER);
		
		JScrollPane scrollPane = new JScrollPane();
		contentPanel.add(scrollPane, BorderLayout.CENTER);
		{
			txtrAaa = new JTextArea();
			txtrAaa.setText("AAA");
			txtrAaa.setBorder(new EmptyBorder(15, 15, 15, 15));
			txtrAaa.setBackground(new Color(255, 255, 153));
			txtrAaa.setOpaque(true);
			txtrAaa.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 10));
			txtrAaa.setEditable(false);
			scrollPane.setViewportView(txtrAaa);
		}
		JPanel buttonPane = new JPanel();
		buttonPane.setLayout(new FlowLayout(FlowLayout.RIGHT));
		getContentPane().add(buttonPane, BorderLayout.SOUTH);
		{
			JButton okButton = new JButton("Close");
			okButton.addActionListener(new ActionListener() {
				@Override
				public void actionPerformed(ActionEvent e) {
					ErrorDialog.this.setVisible(false);
					ErrorDialog.this.dispose();
				}
			});
			buttonPane.add(okButton);
			getRootPane().setDefaultButton(okButton);
		}
		
		setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
	}
	
	public void setText(String t) {
		txtrAaa.setText(t);
		txtrAaa.setCaretPosition(0);
	}
	
	/**
	 * Open an error dialog.
	 *
	 * @param parent
	 *            the parent component
	 * @param text
	 *            the message to display
	 */
	public static void openDialog(Component parent, String text) {
		ErrorDialog dialog = new ErrorDialog(parent);
		dialog.setText(text);
		dialog.setVisible(true);
	}
	
	/**
	 * Open an error dialog.
	 *
	 * @param parent
	 *            the parent component
	 * @param text
	 *            the message to display
	 */
	public static void openDialogBlocking(Component parent, String text) {
		ErrorDialog dialog = new ErrorDialog(parent);
		dialog.setText(text);
		dialog.setModalityType(Dialog.ModalityType.APPLICATION_MODAL);
		dialog.setVisible(true);
		
	}
	
} public class FixedWidthJList<T> extends JList<T> {
	
	private static final long serialVersionUID = -9119825400896096359L;
	
	@Override
	public int getFixedCellWidth() {
		// anything different from -1 will do
		return 0;
	}
	
	public void moveSelectionUp() {
		if (getSelectionMode() != ListSelectionModel.SINGLE_SELECTION) {
			throw new IllegalStateException("list does not use single selection");
		}
		int selectedIndex = getSelectedIndex();
		int size = getModel().getSize();
		if (selectedIndex < 0 && size > 0) {
			// no selection: select first element
			setSelectedIndex(0);
		} else if (selectedIndex > 0 && size >= selectedIndex) {
			// selection is not the first element: select the previous element
			setSelectedIndex(selectedIndex - 1);
		} else if (selectedIndex > 0) {
			setSelectedIndex(size - 1);
		}
	}
	
	public void moveSelectionDown() {
		if (getSelectionMode() != ListSelectionModel.SINGLE_SELECTION) {
			throw new IllegalStateException("list does not use single selection");
		}
		int selectedIndex = getSelectedIndex();
		int size = getModel().getSize();
		if (selectedIndex < 0 && size > 0) {
			// no selection: select first element
			setSelectedIndex(0);
		} else if (selectedIndex < size - 1) {
			// selection is not the last element: select the next element
			setSelectedIndex(selectedIndex + 1);
		} else {
			setSelectedIndex(size - 1);
		}
	}
} public class ImagePreview extends JComponent implements PropertyChangeListener {
	private static final long serialVersionUID = 7477299564536335362L;
	
	ImageIcon thumbnail = null;
	File file = null;
	
	public ImagePreview(JFileChooser fc) {
		setPreferredSize(new Dimension(100, 50));
		fc.addPropertyChangeListener(this);
	}
	
	public void loadImage() {
		if (file == null) {
			thumbnail = null;
			return;
		}
		
		// Don't use createImageIcon (which is a wrapper for getResource)
		// because the image we're trying to load is probably not one
		// of this program's own resources.
		ImageIcon tmpIcon = new ImageIcon(file.getPath());
		if (tmpIcon != null) {
			if (tmpIcon.getIconWidth() > 90) {
				thumbnail = new ImageIcon(tmpIcon.getImage().getScaledInstance(90, -1, Image.SCALE_DEFAULT));
			} else { // no need to miniaturize
				thumbnail = tmpIcon;
			}
		}
	}
	
	@Override
	public void propertyChange(PropertyChangeEvent e) {
		boolean update = false;
		String prop = e.getPropertyName();
		
		// If the directory changed, don't show an image.
		if (JFileChooser.DIRECTORY_CHANGED_PROPERTY.equals(prop)) {
			file = null;
			update = true;
			
			// If a file became selected, find out which one.
		} else if (JFileChooser.SELECTED_FILE_CHANGED_PROPERTY.equals(prop)) {
			file = (File) e.getNewValue();
			update = true;
		}
		
		// Update the preview accordingly.
		if (update) {
			thumbnail = null;
			if (isShowing()) {
				loadImage();
				repaint();
			}
		}
	}
	
	@Override
	protected void paintComponent(Graphics g) {
		if (thumbnail == null) {
			loadImage();
		}
		if (thumbnail != null) {
			int x = getWidth() / 2 - thumbnail.getIconWidth() / 2;
			int y = getHeight() / 2 - thumbnail.getIconHeight() / 2;
			
			if (y < 0) {
				y = 0;
			}
			
			if (x < 5) {
				x = 5;
			}
			thumbnail.paintIcon(this, g, x, y);
		}
	}
} @FunctionalInterface
public interface ListFilter<T> {
	
	/**
	 * Check an object to see if it should be in the filtered list.
	 *
	 * @param object
	 *            the object to check
	 * @return {@code true} if the object should be in the filtered list; {@code false} otherwise
	 */
	boolean isAccepted(T object);
	
} public class TransparentComboBoxModel<T> extends TransparentListModel<T> implements ComboBoxModel<T> {
	
	private static final long serialVersionUID = -1289734610645799530L;
	
	private Object selectedItem;
	
	public TransparentComboBoxModel(List<T> underlyingList) {
		super(underlyingList);
	}
	
	/**
	 * @see javax.swing.ComboBoxModel#setSelectedItem(java.lang.Object)
	 */
	@Override
	public void setSelectedItem(Object anItem) {
		this.selectedItem = anItem;
	}
	
	/**
	 * @see javax.swing.ComboBoxModel#getSelectedItem()
	 */
	@Override
	public Object getSelectedItem() {
		return selectedItem;
	}
	
} public class TransparentFilterableListModel<T> extends TransparentListModel<T> {
	
	private static final long serialVersionUID = -4892272915483148933L;
	
	private final List<T> filteredList = new ArrayList<>();
	private ListFilter<T> filter;
	
	public TransparentFilterableListModel(List<T> underlyingList) {
		super(underlyingList);
		filteredList.addAll(super.getAllElements());
	}
	
	@Override
	public int getSize() {
		return filteredList.size();
	}
	
	@Override
	public T getElementAt(int index) {
		if (filteredList.size() > index) {
			return filteredList.get(index);
		} else {
			return null;
		}
	}
	
	@Override
	public List<T> getAllElements() {
		return filteredList;
	}
	
	@Override
	public boolean contains(T element) {
		return filteredList.contains(element);
	}
	
	public ListFilter<T> getFilter() {
		return filter;
	}
	
	public void setFilter(ListFilter<T> filter) {
		this.filter = filter;
		refilter();
	}
	
	public void refilter() {
		if (filter == null) {
			// everything is accepted
			filteredList.clear();
			filteredList.addAll(super.getAllElements());
		} else {
			filteredList.clear();
			for (T item : super.getAllElements()) {
				if (filter.isAccepted(item)) {
					filteredList.add(item);
				}
			}
		}
		fireContentsChanged(this, 0, getSize());
	}
	
} public class TransparentListModel<T> extends AbstractListModel<T> {
	
	private static final long serialVersionUID = -2952298254786461472L;
	
	private final List<T> underlyingList;
	
	public TransparentListModel(List<T> underlyingList) {
		this.underlyingList = underlyingList;
	}
	
	@Override
	public int getSize() {
		return underlyingList.size();
	}
	
	@Override
	public T getElementAt(int index) {
		return underlyingList.get(index);
	}
	
	public List<T> getAllElements() {
		return underlyingList;
	}
	
	public boolean contains(T element) {
		return underlyingList.contains(element);
	}
	
}
import static org.zephyrsoft.sdb2.model.VirtualScreen.SCREEN_A;
import static org.zephyrsoft.sdb2.model.VirtualScreen.SCREEN_B;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Container;
import java.awt.Cursor;
import java.awt.Desktop;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.GraphicsEnvironment;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Image;
import java.awt.Insets;
import java.awt.KeyboardFocusManager;
import java.awt.Rectangle;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.FocusAdapter;
import java.awt.event.FocusEvent;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.File;
import java.net.URI;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import javax.swing.DefaultListSelectionModel;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JColorChooser;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JEditorPane;
import javax.swing.JFileChooser;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSpinner;
import javax.swing.JSplitPane;
import javax.swing.JTabbedPane;
import javax.swing.JTextField;
import javax.swing.ListSelectionModel;
import javax.swing.ScrollPaneConstants;
import javax.swing.SwingConstants;
import javax.swing.SwingUtilities;
import javax.swing.border.EmptyBorder;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.event.ListSelectionEvent;
import javax.swing.text.JTextComponent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.zephyrsoft.sdb2.FileAndDirectoryLocations;
import org.zephyrsoft.sdb2.MainController;
import org.zephyrsoft.sdb2.gui.KeyboardShortcut.Modifiers;
import org.zephyrsoft.sdb2.gui.renderer.FilterTypeCellRenderer;
import org.zephyrsoft.sdb2.gui.renderer.ScreenContentsCellRenderer;
import org.zephyrsoft.sdb2.gui.renderer.ScreenDisplayCellRenderer;
import org.zephyrsoft.sdb2.gui.renderer.SongCellRenderer;
import org.zephyrsoft.sdb2.model.AddressablePart;
import org.zephyrsoft.sdb2.model.ExportFormat;
import org.zephyrsoft.sdb2.model.FilterTypeEnum;
import org.zephyrsoft.sdb2.model.ScreenContentsEnum;
import org.zephyrsoft.sdb2.model.SelectableDisplay;
import org.zephyrsoft.sdb2.model.Song;
import org.zephyrsoft.sdb2.model.SongsModel;
import org.zephyrsoft.sdb2.model.VirtualScreen;
import org.zephyrsoft.sdb2.model.settings.SettingKey;
import org.zephyrsoft.sdb2.model.settings.SettingsModel;
import org.zephyrsoft.sdb2.presenter.Presentable;
import org.zephyrsoft.sdb2.presenter.ScreenHelper;
import org.zephyrsoft.sdb2.presenter.UIScroller;
import org.zephyrsoft.sdb2.remote.PatchController;
import org.zephyrsoft.sdb2.remote.RemoteStatus;
import org.zephyrsoft.sdb2.service.ExportService;
import org.zephyrsoft.sdb2.service.FieldName;
import org.zephyrsoft.sdb2.service.IndexType;
import org.zephyrsoft.sdb2.service.IndexerService;
import org.zephyrsoft.sdb2.service.IndexerService.OnIndexChangeListener;
import org.zephyrsoft.sdb2.util.CustomFileFilter;
import org.zephyrsoft.sdb2.util.ResourceTools;
import org.zephyrsoft.sdb2.util.StringTools;
import org.zephyrsoft.sdb2.util.VersionTools;
import org.zephyrsoft.sdb2.util.VersionTools.VersionUpdate;
import org.zephyrsoft.sdb2.util.gui.ErrorDialog;
import org.zephyrsoft.sdb2.util.gui.FixedWidthJList;
import org.zephyrsoft.sdb2.util.gui.ImagePreview;
import org.zephyrsoft.sdb2.util.gui.ListFilter;
import org.zephyrsoft.sdb2.util.gui.TransparentComboBoxModel;
import org.zephyrsoft.sdb2.util.gui.TransparentFilterableListModel;
import org.zephyrsoft.sdb2.util.gui.TransparentListModel;
import com.google.common.io.Files;
import say.swing.JFontChooser;
	private JButton btnExportPdfSelected;
	private JButton btnExportPdfAll;
	private JButton btnExportStatisticsAll;
	private JButton btnImportFromSdb1;
	private JLabel lblProgramVersion;
	
	private JButton btnUnlock;
	private JButton btnSelectTitleFont1;
	private JButton btnSelectTitleFont2;
	private JButton btnSelectTitleFontBoth;
	private JButton btnSelectLyricsFont1;
	private JButton btnSelectLyricsFont2;
	private JButton btnSelectLyricsFontBoth;
	private JButton btnSelectTranslationFont1;
	private JButton btnSelectTranslationFont2;
	private JButton btnSelectTranslationFontBoth;
	private JButton btnSelectCopyrightFont1;
	private JButton btnSelectCopyrightFont2;
	private JButton btnSelectCopyrightFontBoth;
	private JButton btnSelectChordSequenceFont1;
	private JButton btnSelectChordSequenceFont2;
	private JButton btnSelectChordSequenceFontBoth;
	private JButton btnSelectTextColor1;
	private JButton btnSelectBackgroundColor1;
	private JButton btnSelectLogo;
	private JSpinner spinnerTopMargin;
	private JSpinner spinnerLeftMargin;
	private JSpinner spinnerRightMargin;
	private JSpinner spinnerBottomMargin;
	private JCheckBox checkboxShowTitle;
	private JSpinner spinnerDistanceTitleText;
	private JSpinner spinnerDistanceTextCopyright;
	private JComboBox<FilterTypeEnum> comboSongListFiltering;
	private JComboBox<SelectableDisplay> comboPresentationScreen1Display;
	private JComboBox<ScreenContentsEnum> comboPresentationScreen1Contents;
	private JComboBox<SelectableDisplay> comboPresentationScreen2Display;
	private JComboBox<ScreenContentsEnum> comboPresentationScreen2Contents;
	private JSpinner spinnerCountAsDisplayedAfter;
	private JButton btnSlideShowDirectory;
	private JSpinner spinnerSlideShowSeconds;
	private JCheckBox checkboxRemoteEnabled;
	private JTextField textFieldRemoteServer;
	private JTextField textFieldRemoteUsername;
	private JTextField textFieldRemotePrefix;
	private JTextField textFieldRemoteRoom;
	private JTextField textFieldRemotePassword;
	private JLabel lblRemoteUsername;
	private JLabel lblRemotePassword;
	private JLabel lblRemoteServer;
	private JLabel lblRemoteEnabled;
	
	private JButton saveButton;
	
	private SongCellRenderer songCellRenderer;
	private JLabel lblSlideShowDirectory;
	private JLabel lblSlideShowSeconds;
	private JButton btnSlideshow;
	private JCheckBox chckbxWithTranslation;
	private JCheckBox chckbxWithChords;
	private JCheckBox chckbxOnlyExportSongs;
	private JLabel labelExportStatisctics;
	private JButton btnSelectTextColor2;
	private JButton btnSelectBackgroundColor2;
	private JButton btnSelectTextColorBoth;
	private JButton btnSelectBackgroundColorBoth;
	private JLabel lblMinimizeScrolling;
	private JCheckBox checkboxMinimizeScrolling1;
	private JCheckBox checkboxMinimizeScrolling2;
	
	private JLabel lblRemotePrefix;
	
	private JLabel lblRemoteRoom;
	
	private JLabel lblStatus;
	
	@Override
	public List<PartButtonGroup> getUIParts() {
		return listSectionButtons;
	}
	
	private void afterConstruction() {
		MainController.initAnimationTimer();
		
		// read program version
		lblProgramVersion.setText(VersionTools.getCurrent());
		
		// fill in available values for filter type
		for (FilterTypeEnum item : FilterTypeEnum.values()) {
			comboSongListFiltering.addItem(item);
		}
		clearSongData();
		// add renderer for filter type
		comboSongListFiltering.setRenderer(new FilterTypeCellRenderer());
		// disable editing fields
		setSongEditingEnabled(false);
		// disable delete and select buttons below songs list
		btnDeleteSong.setEnabled(false);
		btnSelectSong.setEnabled(false);
		// disable buttons beneath the present list
		btnUp.setEnabled(false);
		btnUnselect.setEnabled(false);
		btnDown.setEnabled(false);
		// disable "present this song" and "jump to" buttons
		btnPresentSelectedSong.setEnabled(false);
		btnJumpToSelected.setEnabled(false);
		btnJumpToPresented.setEnabled(false);
		btnExportPdfSelected.setEnabled(false);
		// create empty songsModel for the "selected songs" list
		presentModel = new SongsModel();
		presentListModel = presentModel.getListModel();
		presentList.setModel(presentListModel);
		
		// fill in available values for screen contents
		for (ScreenContentsEnum item : ScreenContentsEnum.values()) {
			comboPresentationScreen1Contents.addItem(item);
			comboPresentationScreen2Contents.addItem(item);
		}
		// add renderer for screen contents
		comboPresentationScreen1Contents.setRenderer(new ScreenContentsCellRenderer());
		comboPresentationScreen2Contents.setRenderer(new ScreenContentsCellRenderer());
		comboPresentationScreen1Display.setRenderer(new ScreenDisplayCellRenderer());