content: package tim.prune.load;
  
  import java.io.File;
  import java.util.ArrayList;
  
  import javax.swing.BoxLayout;
  import javax.swing.JCheckBox;
  import javax.swing.JFileChooser;
  import javax.swing.JFrame;
  import javax.swing.JPanel;
  import javax.swing.SwingUtilities;
  
  import tim.prune.App;
  import tim.prune.I18nManager;
  import tim.prune.cmd.LoadPhotosWithPointsCmd;
  import tim.prune.config.Config;
  import tim.prune.data.Altitude;
  import tim.prune.data.Coordinate;
  import tim.prune.data.DataPoint;
  import tim.prune.data.LatLonRectangle;
  import tim.prune.data.Latitude;
  import tim.prune.data.Longitude;
  import tim.prune.data.Photo;
  import tim.prune.data.Timestamp;
  import tim.prune.data.TimestampLocal;
  import tim.prune.data.TimestampUtc;
  import tim.prune.data.UnitSetLibrary;
  import tim.prune.function.Describer;
  import tim.prune.jpeg.InternalExifLibrary;
  import tim.prune.jpeg.JpegData;
  import tim.prune.tips.TipManager;
  
  /**
   * Class to manage the loading of Jpegs and dealing with the GPS data from them
   */
  public class JpegLoader
  {
  	private final App _app;
  	private final JFrame _parentFrame;
  	private JFileChooser _fileChooser = null;
  	private final GenericFileFilter _fileFilter;
  	private JCheckBox _subdirCheckbox = null;
  	private JCheckBox _noExifCheckbox = null;
  	private JCheckBox _outsideAreaCheckbox = null;
  	private MediaLoadProgressDialog _progressDialog = null;
  	private LoadCounts _counters = null;
  	private boolean _cancelled = false;
  	private LatLonRectangle _trackRectangle = null;
  	private ArrayList<Photo> _photos = null;
  	private boolean _foundDuplicatePhoto = false;
  
  
  	/**
  	 * Constructor
  	 * @param inApp Application object to inform of photo load
  	 * @param inParentFrame parent frame to reference for dialogs
  	 */
  	public JpegLoader(App inApp, JFrame inParentFrame)
  	{
  		_app = inApp;
  		_parentFrame = inParentFrame;
  		_fileFilter = new JpegFileFilter();
  	}
  
  	/** @return function name for dialog title */
  	private String getName() {
  		return I18nManager.getText("menu.file.addphotos");
  	}
  
  	/**
  	 * Open the GUI to select options and start the load
  	 * @param inRectangle track rectangle
  	 */
  	public void openDialog(LatLonRectangle inRectangle)
  	{
  		// Create file chooser if necessary
  		if (_fileChooser == null)
  		{
  			_fileChooser = new JFileChooser();
  			_fileChooser.setMultiSelectionEnabled(true);
  			_fileChooser.setFileSelectionMode(JFileChooser.FILES_AND_DIRECTORIES);
  			_fileChooser.setFileFilter(_fileFilter);
  			_fileChooser.setDialogTitle(getName());
  			_subdirCheckbox = new JCheckBox(I18nManager.getText("dialog.open.includesubdirectories"));
  			_subdirCheckbox.setSelected(true);
  			_noExifCheckbox = new JCheckBox(I18nManager.getText("dialog.jpegload.loadjpegswithoutcoords"));
  			_noExifCheckbox.setSelected(true);
  			_outsideAreaCheckbox = new JCheckBox(I18nManager.getText("dialog.jpegload.loadjpegsoutsidearea"));
  			_outsideAreaCheckbox.setSelected(true);
  			JPanel panel = new JPanel();
  			panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
  			panel.add(_subdirCheckbox);
  			panel.add(_noExifCheckbox);
  			panel.add(_outsideAreaCheckbox);
  			_fileChooser.setAccessory(panel);
  			// start from directory in config if already set by other operations
  			String configDir = _app.getConfig().getConfigString(Config.KEY_PHOTO_DIR);
  			if (configDir == null) {
  				configDir = _app.getConfig().getConfigString(Config.KEY_TRACK_DIR);
  			}
  			if (configDir != null) {
  				_fileChooser.setCurrentDirectory(new File(configDir));
  			}
  		}
  		// enable/disable track checkbox
  		_trackRectangle = inRectangle;
  		_outsideAreaCheckbox.setEnabled(_trackRectangle != null && !_trackRectangle.isEmpty());
  		// Show file dialog to choose file / directory(ies)
  		if (_fileChooser.showOpenDialog(_parentFrame) == JFileChooser.APPROVE_OPTION)
  		{
  			// Bring up dialog before starting
  			_progressDialog = new MediaLoadProgressDialog(_parentFrame, () -> _cancelled = true);
  			_progressDialog.show();
  			// start thread for processing
  			new Thread(this::run).start();
  		}
  	}
  
  	/**
  	 * Run method for performing tasks in separate thread
  	 */
  	public void run()
  	{
  		// Initialise arrays, errors, summaries
  		_counters = new LoadCounts();
  		_photos = new ArrayList<>();
  		File[] files = _fileChooser.getSelectedFiles();
  		_foundDuplicatePhoto = false;
  		// Loop recursively over selected files/directories to count files
  		int numFiles = countFileList(files, true, _subdirCheckbox.isSelected());
  		// Set up the progress bar for this number of files
  		_progressDialog.showProgress(0, numFiles);
  		_cancelled = false;
  
  		// Process the files recursively and build lists of photos
  		processFileList(files, true, _subdirCheckbox.isSelected());
  		SwingUtilities.invokeLater(() -> _progressDialog.close());
  		if (_cancelled) {
  			return;
  		}
  		if (_counters.getNumFiles() == 0) {
  			_app.showErrorMessage("error.jpegload.dialogtitle", "error.jpegload.nofilesfound");
  		}
  		else if (_counters.getNumPhotos() == 0) {
  			_app.showErrorMessage("error.jpegload.dialogtitle", "error.jpegload.nojpegsfound");
  		}
  		else if (!_noExifCheckbox.isSelected() && _counters.getNumPhotos() == 0)
  		{
  			// Need coordinates but no gps information found
  			_app.showErrorMessage("error.jpegload.dialogtitle", "error.jpegload.nogpsfound");
  		}
  		else
  		{
  			// Found some photos to load
  			if (_foundDuplicatePhoto) {
  				_app.showTip(TipManager.Tip_DuplicatePhotoLoaded);
  			}
  			// pass information back to app
  			_photos.sort(new MediaSorter());
  			LoadPhotosWithPointsCmd command = new LoadPhotosWithPointsCmd(_photos);
  			final int numPhotos = _photos.size();
  			Describer confirmDescriber = new Describer("confirm.jpegload.single", "confirm.jpegload.multi");
  			command.setConfirmText(confirmDescriber.getDescriptionWithCount(numPhotos));
  			Describer undoDescriber = new Describer("undo.loadphoto", "undo.loadphotos");
  			String firstPhotoName = _photos.get(0).getName();
  			command.setDescription(undoDescriber.getDescriptionWithNameOrCount(firstPhotoName, numPhotos));
  			// TODO: If app execute says false, then we need to show a message or something?
  			_app.execute(command);
  		}
  	}
  
  
  	/**
  	 * Process a list of files and/or directories
  	 * @param inFiles array of file/directories
  	 * @param inFirstDir true if first directory
  	 * @param inDescend true to descend to subdirectories
  	 */
  	private void processFileList(File[] inFiles, boolean inFirstDir, boolean inDescend)
  	{
  		if (inFiles == null) {
  			return;
  		}
  		// Loop over elements in array
  		for (File file : inFiles)
  		{
  			if (!_cancelled && file.exists() && file.canRead())
  			{
  				// Check whether it's a file or a directory
  				if (file.isFile()) {
  					processFile(file);
  				}
  				else if (file.isDirectory() && (inFirstDir || inDescend))
  				{
  					// Always process first directory,
  					// only process subdirectories if checkbox selected
  					File[] files = file.listFiles();
  					processFileList(files, false, inDescend);
  				}
  			}
  			// if file doesn't exist or isn't readable - ignore
  		}
  	}
  
  
  	/**
  	 * Process the given file, by attempting to extract its tags
  	 * @param inFile file object to read
  	 */
  	private void processFile(File inFile)
  	{
  		_counters.foundFile();
  		_progressDialog.setValue(_counters.getNumFiles());
  
  		// Check whether filename corresponds with accepted filenames
  		if (!_fileFilter.acceptFilename(inFile.getName())) {
  			return;
  		}
  
  		if (inFile.exists() && inFile.canRead()) {
  			_counters.foundPhoto();
  		}
  		Photo photo = createPhoto(inFile);
  		if (photo.getDataPoint() != null) {
  			_counters.foundPhotoWithCoords();
  		}
  		// Check the criteria for adding the photo - check whether the photo has coordinates and if so if they're within the rectangle
  		if ( (photo.getDataPoint() != null || _noExifCheckbox.isSelected())
  			&& (photo.getDataPoint() == null || !_outsideAreaCheckbox.isEnabled()
  				|| _outsideAreaCheckbox.isSelected() || _trackRectangle.containsPoint(photo.getDataPoint())))
  		{
  			_photos.add(photo);
  			if (_app.getTrackInfo().getPhotoList().hasDuplicate(photo)) {
  				_foundDuplicatePhoto = true;
  			}
  		}
  	}
  
  	/**
  	 * Create a Photo object for the given file, including reading exif information
  	 * @param inFile file object
  	 * @return Photo object
  	 */
  	public static Photo createPhoto(File inFile)
  	{
  		// Create Photo object
  		Photo photo = new Photo(inFile);
  		// Try to get information out of exif
  		JpegData jpegData = new InternalExifLibrary().getJpegData(inFile);
  		Timestamp timestamp = null;
  		if (jpegData != null)
  		{
  			if (jpegData.isGpsValid())
  			{
  				timestamp = createTimestamp(jpegData.getGpsDatestamp(), jpegData.getGpsTimestamp());
  				// Make DataPoint and attach to Photo
  				DataPoint point = createDataPoint(jpegData);
  				point.setPhoto(photo);
  				point.setSegmentStart(true);
  				photo.setDataPoint(point);
  				photo.setOriginalStatus(Photo.Status.TAGGED);
  			}
  			// Use exif timestamp if gps timestamp not available
  			if (timestamp == null && jpegData.getOriginalTimestamp() != null) {
  				timestamp = createTimestamp(jpegData.getOriginalTimestamp());
  			}
  			if (timestamp == null && jpegData.getDigitizedTimestamp() != null) {
  				timestamp = createTimestamp(jpegData.getDigitizedTimestamp());
  			}
  			photo.setExifThumbnail(jpegData.getThumbnailImage());
  			// Also extract orientation tag for setting rotation state of photo
  			photo.setRotation(jpegData.getRequiredRotation());
  			// Set bearing, if any
  			photo.setBearing(jpegData.getBearing());
  		}
  		// Use file timestamp if exif timestamp isn't available
  		if (timestamp == null) {
  			timestamp = new TimestampUtc(inFile.lastModified());
  		}
  		// Apply timestamp to photo (but not its point)
  		photo.setTimestamp(timestamp);
  
  		return photo;
  	}
  
  
  	/**
  	 * Recursively count the selected Files so we can draw a progress bar
  	 * @param inFiles file list
  	 * @param inFirstDir true if first directory
  	 * @param inDescend true to descend to subdirectories
  	 * @return count of the files selected
  	 */
  	private int countFileList(File[] inFiles, boolean inFirstDir, boolean inDescend)
  	{
  		int fileCount = 0;
  		if (inFiles != null)
  		{
  			// Loop over elements in array
  			for (int i=0; i<inFiles.length; i++)
  			{
  				File file = inFiles[i];
  				if (file.exists() && file.canRead())
  				{
  					// Store first directory in config for later
  					if (i == 0 && inFirstDir)
  					{
  						File workingDir = file.isDirectory() ? file : file.getParentFile();
  						_app.getConfig().setConfigString(Config.KEY_PHOTO_DIR, workingDir.getAbsolutePath());
  					}
  					// Check whether it's a file or a directory
  					if (file.isFile()) {
  						fileCount++;
  					}
  					else if (file.isDirectory() && (inFirstDir || inDescend)) {
  						fileCount += countFileList(file.listFiles(), false, inDescend);
  					}
  				}
  			}
  		}
  		return fileCount;
  	}
  
  
  	/**
  	 * Create a DataPoint object from the given jpeg data
  	 * @param inData Jpeg data including coordinates
  	 * @return DataPoint object for Track
  	 */
  	private static DataPoint createDataPoint(JpegData inData)
  	{
  		// Create model objects from jpeg data
  		double latval = getCoordinateDoubleValue(inData.getLatitude(),
  			inData.getLatitudeRef() == 'N' || inData.getLatitudeRef() == 'n');
  		Coordinate latitude = Latitude.make(latval);
  		double lonval = getCoordinateDoubleValue(inData.getLongitude(),
  			inData.getLongitudeRef() == 'E' || inData.getLongitudeRef() == 'e');
  		Coordinate longitude = Longitude.make(lonval);
  		Altitude altitude = null;
  		if (inData.hasAltitude()) {
  			altitude = new Altitude(inData.getAltitude(), UnitSetLibrary.UNITS_METRES);
  		}
  		return new DataPoint(latitude, longitude, altitude);
  	}
  
  
  	/**
  	 * Convert an array of 3 doubles (deg-min-sec) into a double coordinate value
  	 * @param inValues array of three doubles for deg-min-sec
  	 * @param isPositive true for positive hemisphere, for positive double value
  	 * @return double value of coordinate, either positive or negative
  	 */
  	private static double getCoordinateDoubleValue(double[] inValues, boolean isPositive)
  	{
  		if (inValues == null || inValues.length != 3) {
  			return 0.0;
  		}
  		double value = inValues[0]        // degrees
  			+ inValues[1] / 60.0          // minutes
  			+ inValues[2] / 60.0 / 60.0;  // seconds
  		// make sure it's the correct sign
  		value = Math.abs(value);
  		return isPositive ? value : -value;
  	}
  
  
  	/**
  	 * Use the given int values to create a timestamp
  	 * @param inDate ints describing date
  	 * @param inTime ints describing time
  	 * @return Timestamp object corresponding to inputs
  	 */
  	private static Timestamp createTimestamp(int[] inDate, int[] inTime)
  	{
  		if (inDate == null || inTime == null || inDate.length != 3 || inTime.length != 3) {
  			return null;
  		}
  		return new TimestampLocal(inDate[0], inDate[1], inDate[2],
  			inTime[0], inTime[1], inTime[2]);
  	}
  
  
  	/**
  	 * Use the given String value to create a timestamp
  	 * @param inStamp timestamp from exif
  	 * @return Timestamp object corresponding to input
  	 */
  	private static Timestamp createTimestamp(String inStamp)
  	{
  		Timestamp stamp = null;
  		try
  		{
  			stamp = new TimestampLocal(Integer.parseInt(inStamp.substring(0, 4)),
  				Integer.parseInt(inStamp.substring(5, 7)),
  				Integer.parseInt(inStamp.substring(8, 10)),
  				Integer.parseInt(inStamp.substring(11, 13)),
  				Integer.parseInt(inStamp.substring(14, 16)),
  				Integer.parseInt(inStamp.substring(17)));
  		}
  		catch (NumberFormatException | StringIndexOutOfBoundsException e) {}
  		return stamp;
  	}
  }
  