content: package tim.prune.function.settings;
  
  import java.awt.BorderLayout;
  import java.awt.Component;
  import java.awt.Dimension;
  import java.awt.FlowLayout;
  import java.awt.GridLayout;
  import java.awt.event.ActionListener;
  import java.awt.event.FocusAdapter;
  import java.awt.event.FocusEvent;
  import java.awt.event.FocusListener;
  import java.awt.event.KeyAdapter;
  import java.awt.event.KeyEvent;
  import java.util.ArrayList;
  import java.util.TimeZone;
  import java.util.TreeSet;
  
  import javax.swing.BorderFactory;
  import javax.swing.BoxLayout;
  import javax.swing.ButtonGroup;
  import javax.swing.JButton;
  import javax.swing.JDialog;
  import javax.swing.JLabel;
  import javax.swing.JPanel;
  import javax.swing.JRadioButton;
  import javax.swing.JScrollPane;
  import javax.swing.event.ListSelectionEvent;
  import javax.swing.event.ListSelectionListener;
  
  import tim.prune.App;
  import tim.prune.DataSubscriber;
  import tim.prune.GenericFunction;
  import tim.prune.I18nManager;
  import tim.prune.UpdateMessageBroker;
  import tim.prune.config.Config;
  import tim.prune.gui.CombinedListAndModel;
  import tim.prune.gui.GuiGridLayout;
  
  /**
   * Class to provide the gui for selecting an alternative timezone
   */
  public class SelectTimezoneFunction extends GenericFunction
  {
  	/** Arraylist of timezone infos */
  	private ArrayList<TimezoneDetails> _zoneInfo;
  	/** Dialog */
  	private JDialog _dialog = null;
  	/** Radio button to select system timezone instead of using listboxes */
  	private JRadioButton _systemRadio = null;
  	/** Radio button to select timezone using listboxes */
  	private JRadioButton _customRadio = null;
  	/** Array of list boxes */
  	private CombinedListAndModel[] _listBoxes = null;
  	/** Label for selected zone */
  	private JLabel _selectedZoneLabel = null;
  	/** Label for offset of selected zone */
  	private JLabel _selectedOffsetLabel = null;
  	/** OK button for finishing */
  	private JButton _okButton = null;
  
  	private static final int LIST_REGIONS = 0;
  	private static final int LIST_OFFSETS = 1;
  	private static final int LIST_GROUPS  = 2;
  	private static final int LIST_NAMES   = 3;
  
  	/**
  	 * Inner class for listening to list clicks
  	 */
  	class ListListener implements ListSelectionListener
  	{
  		private int _key = 0;
  		/** Constructor */
  		ListListener(int inKey) {_key = inKey;}
  		/** Listen for selection changes */
  		public void valueChanged(ListSelectionEvent inEvent) {
  			if (!inEvent.getValueIsAdjusting()) {
  				processListClick(_key);
  			}
  		}
  	}
  
  	/** Inner class to hold categorisation info for a timezone */
  	private static class TimezoneDetails
  	{
  		public String _id;
  		public String _region;
  		public int    _offset;
  		public String _group;
  		public String _name;
  	}
  
  	/**
  	 * Constructor
  	 * @param inApp App object
  	 */
  	public SelectTimezoneFunction(App inApp)
  	{
  		super(inApp);
  	}
  
  	/** Get the name key */
  	public String getNameKey() {
  		return "function.selecttimezone";
  	}
  
  	/**
  	 * Begin the function
  	 */
  	public void begin()
  	{
  		if (_dialog == null)
  		{
  			_dialog = new JDialog(_parentFrame, getName(), true);
  			_dialog.setLocationRelativeTo(_parentFrame);
  			_dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
  			_dialog.getContentPane().add(makeDialogComponents());
  			_dialog.pack();
  		}
  		collectTimezoneInfo();
  		_systemRadio.setText(I18nManager.getText("dialog.settimezone.system") + " ("
  			+ TimeZone.getDefault().getID() + ")");
  		// Set up dialog according to current config
  		String selectedTimezone = getConfig().getConfigString(Config.KEY_TIMEZONE_ID);
  		if (selectedTimezone == null || selectedTimezone.equals(""))
  		{
  			_systemRadio.setSelected(true);
  		}
  		else
  		{
  			_customRadio.setSelected(true);
  		}
  		_dialog.setVisible(true);
  	}
  
  	/**
  	 * Create dialog components
  	 * @return Panel containing all gui elements in dialog
  	 */
  	private Component makeDialogComponents()
  	{
  		JPanel dialogPanel = new JPanel();
  		dialogPanel.setLayout(new BorderLayout(5, 5));
  		// Listener for radio buttons
  		ActionListener radioListener = e -> {
  			radioSelected(_systemRadio.isSelected());
  		};
  		FocusListener radioFocusListener = new FocusAdapter() {
  			public void focusGained(FocusEvent inEvent) {
  				radioSelected(_systemRadio.isSelected());
  			}
  		};
  
  		// Panel at top
  		JPanel topPanel = new JPanel();
  		topPanel.setLayout(new BoxLayout(topPanel, BoxLayout.Y_AXIS));
  		JLabel topLabel = new JLabel(I18nManager.getText("dialog.settimezone.intro"));
  		topLabel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
  		topPanel.add(topLabel);
  		_systemRadio = new JRadioButton(I18nManager.getText("dialog.settimezone.system"));
  		_systemRadio.addActionListener(radioListener);
  		_systemRadio.addFocusListener(radioFocusListener);
  		topPanel.add(_systemRadio);
  		_customRadio = new JRadioButton(I18nManager.getText("dialog.settimezone.custom"));
  		_customRadio.addActionListener(radioListener);
  		_customRadio.addFocusListener(radioFocusListener);
  		topPanel.add(_customRadio);
  		ButtonGroup radioGroup = new ButtonGroup();
  		radioGroup.add(_systemRadio); radioGroup.add(_customRadio);
  		dialogPanel.add(topPanel, BorderLayout.NORTH);
  
  		// Main panel with box layout, list Panel with four lists in a grid
  		JPanel mainPanel = new JPanel();
  		mainPanel.setLayout(new BoxLayout(mainPanel, BoxLayout.Y_AXIS));
  
  		JPanel listsPanel = new JPanel();
  		listsPanel.setLayout(new GridLayout(1, 4));
  		_listBoxes = new CombinedListAndModel[4];
  		// First list for regions
  		_listBoxes[LIST_REGIONS] = new CombinedListAndModel(0);
  		// Add listener for list selection changes
  		_listBoxes[LIST_REGIONS].addListSelectionListener(new ListListener(LIST_REGIONS));
  		JScrollPane scrollPane = new JScrollPane(_listBoxes[LIST_REGIONS]);
  		scrollPane.setPreferredSize(new Dimension(100, 200));
  		scrollPane.setMinimumSize(new Dimension(100, 200));
  		scrollPane.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
  		listsPanel.add(scrollPane);
  
  		// second list for offsets
  		_listBoxes[LIST_OFFSETS] = new CombinedListAndModel(1);
  		_listBoxes[LIST_OFFSETS].setMaxNumEntries(24);
  		_listBoxes[LIST_OFFSETS].addListSelectionListener(new ListListener(LIST_OFFSETS));
  		scrollPane = new JScrollPane(_listBoxes[LIST_OFFSETS]);
  		scrollPane.setPreferredSize(new Dimension(100, 200));
  		scrollPane.setMinimumSize(new Dimension(100, 200));
  		scrollPane.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
  		listsPanel.add(scrollPane);
  
  		// third list for groups
  		_listBoxes[LIST_GROUPS] = new CombinedListAndModel(2);
  		_listBoxes[LIST_GROUPS].setMaxNumEntries(20);
  		_listBoxes[LIST_GROUPS].addListSelectionListener(new ListListener(LIST_GROUPS));
  		scrollPane = new JScrollPane(_listBoxes[LIST_GROUPS]);
  		scrollPane.setPreferredSize(new Dimension(100, 200));
  		scrollPane.setMinimumSize(new Dimension(100, 200));
  		scrollPane.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
  		listsPanel.add(scrollPane);
  
  		// fourth list for names
  		_listBoxes[LIST_NAMES] = new CombinedListAndModel(3);
  		_listBoxes[LIST_NAMES].setMaxNumEntries(20);
  		_listBoxes[LIST_NAMES].addListSelectionListener(new ListListener(LIST_NAMES));
  		scrollPane = new JScrollPane(_listBoxes[LIST_NAMES]);
  		scrollPane.setPreferredSize(new Dimension(100, 200));
  		scrollPane.setMinimumSize(new Dimension(100, 200));
  		scrollPane.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
  		listsPanel.add(scrollPane);
  		mainPanel.add(listsPanel);
  
  		// Details labels underneath lists - description and offset
  		JPanel detailsPanel = new JPanel();
  		GuiGridLayout grid = new GuiGridLayout(detailsPanel);
  		grid.add(new JLabel(I18nManager.getText("dialog.settimezone.selectedzone") + " :"));
  		_selectedZoneLabel = new JLabel("");
  		grid.add(_selectedZoneLabel);
  		grid.add(new JLabel(I18nManager.getText("dialog.settimezone.offsetfromutc") + " :"));
  		_selectedOffsetLabel = new JLabel("");
  		grid.add(_selectedOffsetLabel);
  		mainPanel.add(detailsPanel);
  		dialogPanel.add(mainPanel, BorderLayout.CENTER);
  
  		// close window if escape pressed
  		KeyAdapter escListener = new KeyAdapter() {
  			public void keyReleased(KeyEvent inE) {
  				if (inE.getKeyCode() == KeyEvent.VK_ESCAPE) {
  					_dialog.dispose();
  				}
  			}
  		};
  		_listBoxes[LIST_REGIONS].addKeyListener(escListener);
  		_listBoxes[LIST_OFFSETS].addKeyListener(escListener);
  
  		// button panel at bottom
  		JPanel buttonPanel = new JPanel();
  		buttonPanel.setLayout(new FlowLayout(FlowLayout.RIGHT));
  		// OK button
  		_okButton = new JButton(I18nManager.getText("button.ok"));
  		_okButton.addActionListener(e -> finishSelectTimezone());
  		buttonPanel.add(_okButton);
  		_okButton.addKeyListener(escListener);
  		// Cancel button
  		JButton cancelButton = new JButton(I18nManager.getText("button.cancel"));
  		cancelButton.addActionListener(e -> _dialog.dispose());
  		cancelButton.addKeyListener(new KeyAdapter() {
  			public void keyPressed(KeyEvent inE) {
  				if (inE.getKeyCode() == KeyEvent.VK_ESCAPE) {_dialog.dispose();}
  			}
  		});
  		buttonPanel.add(cancelButton);
  		dialogPanel.add(buttonPanel, BorderLayout.SOUTH);
  		return dialogPanel;
  	}
  
  	/**
  	 * React to changes in the radio buttons
  	 * @param inUseSystem true for system, false for custom
  	 */
  	private void radioSelected(boolean inUseSystem)
  	{
  		for (CombinedListAndModel listBox : _listBoxes)
  		{
  			if (inUseSystem) {
  				listBox.clear();
  			}
  			listBox.setEnabled(!inUseSystem);
  		}
  		if (!inUseSystem)
  		{
  			populateTimezoneRegions();
  			populateTimezoneOffsets(null);
  			preselectTimezone(getConfig().getConfigString(Config.KEY_TIMEZONE_ID));
  		}
  		showTimezoneDetails();
  	}
  
  	/**
  	 * React to a selection change on one of our lists
  	 * @param inKey key of list which was clicked
  	 */
  	private void processListClick(int inKey)
  	{
  		final boolean offsetSelected = _listBoxes[LIST_OFFSETS].getSelectedItem() != null;
  		final boolean groupSelected = _listBoxes[LIST_GROUPS].getSelectedItem() != null;
  		// Update offsets?
  		if (inKey == LIST_REGIONS)
  		{
  			populateTimezoneOffsets(_listBoxes[LIST_REGIONS].getSelectedItem());
  		}
  		// Update groups?
  		if (inKey == LIST_OFFSETS
  			|| (inKey == LIST_REGIONS && !offsetSelected))
  		{
  			populateTimezoneGroups(_listBoxes[LIST_REGIONS].getSelectedItem(), _listBoxes[LIST_OFFSETS].getSelectedItem());
  		}
  		// Update names?
  		if (inKey == LIST_GROUPS
  			|| (inKey <= LIST_OFFSETS && !groupSelected))
  		{
  			populateTimezoneNames(_listBoxes[LIST_REGIONS].getSelectedItem(), _listBoxes[LIST_OFFSETS].getSelectedItem(),
  				_listBoxes[LIST_GROUPS].getSelectedItem());
  		}
  		// Show the details of the selected timezone
  		showTimezoneDetails();
  	}
  
  	/**
  	 * Use the system information to populate the list of available timezones
  	 */
  	private void collectTimezoneInfo()
  	{
  		_zoneInfo = new ArrayList<TimezoneDetails>();
  		for (String id : TimeZone.getAvailableIDs())
  		{
  			String region = getRegion(id);
  			if (region != null)
  			{
  				TimeZone tz = TimeZone.getTimeZone(id);
  				TimezoneDetails details = new TimezoneDetails();
  				details._id = id;
  				details._region = region;
  				details._offset = tz.getOffset(System.currentTimeMillis()) / 1000 / 60;
  				details._group = tz.getDisplayName();
  				details._name = getNameWithoutRegion(id);
  				_zoneInfo.add(details);
  			}
  		}
  	}
  
  	/**
  	 * Populate the timezone regions into the region list
  	 */
  	private void populateTimezoneRegions()
  	{
  		_listBoxes[LIST_REGIONS].clear();
  		TreeSet<String> regions = new TreeSet<String>();
  		for (TimezoneDetails currZone : _zoneInfo)
  		{
  			regions.add(currZone._region);
  		}
  		for (String region : regions)
  		{
  			_listBoxes[LIST_REGIONS].addItem(region);
  		}
  	}
  
  	/**
  	 * Extract the timezone region from the id
  	 */
  	private static String getRegion(String inId)
  	{
  		final int slashPos = (inId == null ? -1 : inId.indexOf('/'));
  		if (slashPos > 0)
  		{
  			return inId.substring(0, slashPos);
  		}
  		return null;
  	}
  
  	/**
  	 * Populate the second listbox with the offsets for the given region
  	 * @param inRegion selected region, or null if none selected
  	 */
  	private void populateTimezoneOffsets(String inRegion)
  	{
  		_listBoxes[LIST_OFFSETS].clear();
  		TreeSet<Integer> offsetsinMinutes = new TreeSet<Integer>();
  		for (TimezoneDetails currZone : _zoneInfo)
  		{
  			String region = currZone._region;
  			if (inRegion == null || region.equals(inRegion))
  			{
  				offsetsinMinutes.add(currZone._offset);
  			}
  		}
  		for (Integer offset : offsetsinMinutes)
  		{
  			_listBoxes[LIST_OFFSETS].addItem(makeOffsetString(offset));
  		}
  	}
  
  	/**
  	 * @return String containing offset for display
  	 */
  	private static String makeOffsetString(int inOffsetInMinutes)
  	{
  		if (inOffsetInMinutes == 0) return "0";
  		final boolean isWholeHours = (inOffsetInMinutes % 60) == 0;
  		if (isWholeHours)
  		{
  			return (inOffsetInMinutes > 0 ? "+" : "") + (inOffsetInMinutes / 60);
  		}
  		final double numHours = inOffsetInMinutes / 60.0;
  		return (inOffsetInMinutes > 0 ? "+" : "") + numHours;
  	}
  
  	/**
  	 * Populate the group list using the specified region and offset
  	 * @param inRegion selected region (if any) from the first list
  	 * @param inOffset selected offset (if any) from the second list
  	 */
  	private void populateTimezoneGroups(String inRegion, String inOffset)
  	{
  		_listBoxes[LIST_GROUPS].clear();
  		// Convert given offset string (in hours) into numeric offset (in minutes)
  		final int offsetMins = convertToMinutes(inOffset);
  
  		TreeSet<String> zoneGroups = new TreeSet<String>();
  		for (TimezoneDetails currZone : _zoneInfo)
  		{
  			if (inRegion == null || currZone._region.equals(inRegion))
  			{
  				if (offsetMins == -1 || offsetMins == currZone._offset)
  				{
  					zoneGroups.add(currZone._group);
  				}
  			}
  		}
  		// If the region and offset were given, then list is unlimited
  		_listBoxes[LIST_GROUPS].setUnlimited(inRegion != null && inOffset != null);
  		// Add all the found names to the listbox
  		for (String group : zoneGroups)
  		{
  			_listBoxes[LIST_GROUPS].addItem(group);
  		}
  	}
  
  	/**
  	 * Populate the group list using the specified region, offset and group
  	 * @param inRegion selected region (if any) from the first list
  	 * @param inOffset selected offset (if any) from the second list
  	 * @param inGroup selected group (if any) from the third list
  	 */
  	private void populateTimezoneNames(String inRegion, String inOffset, String inGroup)
  	{
  		CombinedListAndModel nameList = _listBoxes[LIST_NAMES];
  		nameList.clear();
  		// Convert given offset string (in hours) into numeric offset (in minutes)
  		final int offsetMins = convertToMinutes(inOffset);
  
  		TreeSet<String> zoneNames = new TreeSet<String>();
  		for (TimezoneDetails currZone : _zoneInfo)
  		{
  			if ((inRegion == null || currZone._region.equals(inRegion))
  				&& (offsetMins == -1 || currZone._offset == offsetMins)
  				&& (inGroup == null || currZone._group.equals(inGroup)))
  			{
  				zoneNames.add(currZone._name);
  			}
  		}
  		// If the region and offset were given, then list is unlimited
  		nameList.setUnlimited(inRegion != null && inOffset != null);
  		// Add all the found names to the listbox
  		for (String name : zoneNames)
  		{
  			nameList.addItem(name);
  		}
  	}
  
  	/**
  	 * Convert the given String from hours to minutes
  	 * @param inOffsetInHours String from listbox in +/- hours
  	 * @return offset in minutes, or -1
  	 */
  	private static int convertToMinutes(String inOffsetInHours)
  	{
  		try {
  			return (int) (60 * Double.parseDouble(inOffsetInHours));
  		}
  		catch (NumberFormatException | NullPointerException nfe) {
  			return -1;
  		}
  	}
  
  	/**
  	 * Remove the timezone region from the id to just leave the name after the slash
  	 */
  	private static String getNameWithoutRegion(String inId)
  	{
  		final int slashPos = (inId == null ? -1 : inId.indexOf('/'));
  		if (slashPos > 0) {
  			return inId.substring(slashPos + 1);
  		}
  		return null;
  	}
  
  	/**
  	 * Get the selected timezone, or null if none selected
  	 */
  	private TimeZone getSelectedTimezone()
  	{
  		if (_systemRadio.isSelected()) {
  			return TimeZone.getDefault();
  		}
  
  		String chosenRegion = _listBoxes[LIST_REGIONS].getSelectedItem();
  		// Convert given offset string (in hours) into numeric offset (in minutes)
  		final int offsetMins = convertToMinutes(_listBoxes[LIST_OFFSETS].getSelectedItem());
  		String chosenGroup = _listBoxes[LIST_GROUPS].getSelectedItem();
  		String chosenName = _listBoxes[LIST_NAMES].getSelectedItem();
  
  		TreeSet<String> zoneIds = new TreeSet<String>();
  		for (TimezoneDetails currZone : _zoneInfo)
  		{
  			if ((chosenRegion == null || currZone._region.equals(chosenRegion))
  				&& (offsetMins == -1 || currZone._offset == offsetMins)
  				&& (chosenGroup == null || currZone._group.equals(chosenGroup))
  				&& (chosenName == null || currZone._name.equals(chosenName)))
  			{
  				zoneIds.add(currZone._id);
  				if (zoneIds.size() > 1) {
  					break;	// exit loop now, we've got too many
  				}
  			}
  		}
  		// Should have exactly one result now
  		if (zoneIds.size() == 1)
  		{
  			return TimeZone.getTimeZone(zoneIds.first());
  		}
  
  		// none selected (yet)
  		return null;
  	}
  
  	/**
  	 * Show the details of the selected timezone
  	 */
  	private void showTimezoneDetails()
  	{
  		TimeZone selectedTimezone = getSelectedTimezone();
  		if (selectedTimezone == null)
  		{
  			// Clear details labels
  			_selectedZoneLabel.setText("");
  			_selectedOffsetLabel.setText("");
  		}
  		else
  		{
  			// Fill results in labels
  			String desc = selectedTimezone.getID() + " - " + selectedTimezone.getDisplayName();
  			_selectedZoneLabel.setText(desc);
  			String offsets = getOffsetDescription(selectedTimezone);
  			_selectedOffsetLabel.setText(offsets);
  		}
  		_okButton.setEnabled(selectedTimezone != null);
  	}
  
  	/**
  	 * @param inTimezone selected timezone
  	 * @return String describing the time offset(s) of this zone including winter/summer time
  	 */
  	private static String getOffsetDescription(TimeZone inTimezone)
  	{
  		if (inTimezone == null) {
  			return "";
  		}
  		TreeSet<Integer> offsetsinMinutes = new TreeSet<Integer>();
  		long testTimeMillis = System.currentTimeMillis();
  		final long testPeriodInMillis = 1000L * 60 * 60 * 24 * 30 * 2;
  		for (int i=0; i<5; i++)
  		{
  			offsetsinMinutes.add(inTimezone.getOffset(testTimeMillis) / 1000 / 60);
  			testTimeMillis += testPeriodInMillis;
  		}
  		// Make String describing the sorted set
  		StringBuilder builder = new StringBuilder();
  		for (Integer offset : offsetsinMinutes)
  		{
  			if (builder.length() > 0) {
  				builder.append(" / ");
  			}
  			builder.append(makeOffsetString(offset));
  		}
  		return builder.toString();
  	}
  
  	/**
  	 * On entry to the dialog, select the items in each listbox
  	 * according to the given preselected timezone id
  	 * @param zoneId id of zone to select
  	 */
  	private void preselectTimezone(String zoneId)
  	{
  		TimeZone tz = (zoneId == null ? TimeZone.getDefault() : TimeZone.getTimeZone(zoneId));
  		if (tz != null)
  		{
  			_listBoxes[LIST_REGIONS].selectItem(getRegion(zoneId));
  			_listBoxes[LIST_OFFSETS].selectItem(makeOffsetString(tz.getOffset(System.currentTimeMillis()) / 1000 / 60));
  			_listBoxes[LIST_GROUPS].selectItem(tz.getDisplayName());
  			_listBoxes[LIST_NAMES].selectItem(getNameWithoutRegion(zoneId));
  		}
  	}
  
  	/**
  	 * Finish the dialog by setting the config according to the selected zone
  	 */
  	private void finishSelectTimezone()
  	{
  		TimeZone selectedTimezone = getSelectedTimezone();
  		if (_systemRadio.isSelected() || selectedTimezone == null)
  		{
  			// Clear config, use default system timezone instead
  			getConfig().setConfigString(Config.KEY_TIMEZONE_ID, null);
  		}
  		else
  		{
  			// Get selected timezone, set in config
  			getConfig().setConfigString(Config.KEY_TIMEZONE_ID, selectedTimezone.getID());
  		}
  		_dialog.dispose();
  		// Make sure listeners know to update themselves
  		UpdateMessageBroker.informSubscribers(DataSubscriber.UNITS_CHANGED);
  	}
  }
  