/*
 * PhotoPanel.java 5 mai 2009
 *
 * Sweet Home 3D, Copyright (c) 2024 Space Mushrooms <info@sweethome3d.com>
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 */
package com.eteks.sweethome3d.swing;

import java.awt.CardLayout;
import java.awt.Component;
import java.awt.ComponentOrientation;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.Graphics2D;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.Window;
import java.awt.datatransfer.DataFlavor;
import java.awt.datatransfer.Transferable;
import java.awt.datatransfer.UnsupportedFlavorException;
import java.awt.event.ActionEvent;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.ItemEvent;
import java.awt.event.ItemListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.awt.image.BufferedImage;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.io.File;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import javax.imageio.ImageIO;
import javax.imageio.ImageWriter;
import javax.imageio.stream.FileImageOutputStream;
import javax.swing.ActionMap;
import javax.swing.DefaultListCellRenderer;
import javax.swing.Icon;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JSeparator;
import javax.swing.JSpinner;
import javax.swing.KeyStroke;
import javax.swing.SpinnerDateModel;
import javax.swing.SwingUtilities;
import javax.swing.TransferHandler;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;

import com.eteks.sweethome3d.j3d.AbstractPhotoRenderer;
import com.eteks.sweethome3d.model.AspectRatio;
import com.eteks.sweethome3d.model.Camera;
import com.eteks.sweethome3d.model.Camera.Lens;
import com.eteks.sweethome3d.model.Home;
import com.eteks.sweethome3d.model.HomeEnvironment;
import com.eteks.sweethome3d.model.Selectable;
import com.eteks.sweethome3d.model.UserPreferences;
import com.eteks.sweethome3d.tools.OperatingSystem;
import com.eteks.sweethome3d.viewcontroller.AbstractPhotoController;
import com.eteks.sweethome3d.viewcontroller.ContentManager;
import com.eteks.sweethome3d.viewcontroller.DialogView;
import com.eteks.sweethome3d.viewcontroller.Object3DFactory;
import com.eteks.sweethome3d.viewcontroller.PhotoController;
import com.eteks.sweethome3d.viewcontroller.View;

/**
 * A panel to edit photo creation.
 * @author Emmanuel Puybaret
 */
public class PhotoPanel extends JPanel implements DialogView {
  private enum ActionType {START_PHOTO_CREATION, STOP_PHOTO_CREATION, SAVE_PHOTO, CLOSE}

  private static final String PHOTO_DIALOG_X_VISUAL_PROPERTY = "com.eteks.sweethome3d.swing.PhotoPanel.PhotoDialogX";
  private static final String PHOTO_DIALOG_Y_VISUAL_PROPERTY = "com.eteks.sweethome3d.swing.PhotoPanel.PhotoDialogY";
  private static final String PHOTO_DIALOG_WIDTH_VISUAL_PROPERTY = "com.eteks.sweethome3d.swing.PhotoPanel.PhotoDialogWidth";
  private static final String PHOTO_DIALOG_HEIGHT_VISUAL_PROPERTY = "com.eteks.sweethome3d.swing.PhotoPanel.PhotoDialogHeight";

  private static final int MINIMUM_DELAY_BEFORE_DISCARDING_WITHOUT_WARNING = 30000;

  private static final String WAIT_CARD  = "wait";
  private static final String PHOTO_CARD = "photo";

  private final Home               home;
  private final UserPreferences    preferences;
  private final Object3DFactory    object3dFactory;
  private final PhotoController    controller;
  private ScaledImageComponent     photoComponent;
  private JLabel                   animatedWaitLabel;
  private PhotoSizeAndQualityPanel sizeAndQualityPanel;
  private Component                advancedComponentsSeparator;
  private JLabel                   dateLabel;
  private JSpinner                 dateSpinner;
  private JLabel                   timeLabel;
  private JSpinner                 timeSpinner;
  private JLabel                   dayNightLabel;
  private JLabel                   lensLabel;
  private JComboBox                lensComboBox;
  private JCheckBox                ceilingLightEnabledCheckBox;
  private JLabel                   rendererLabel;
  private JComboBox                rendererComboBox;
  private String                   dialogTitle;
  private JPanel                   photoPanel;
  private CardLayout               photoCardLayout;
  private ExecutorService          photoCreationExecutor;
  private long                     photoCreationStartTime;
  private JButton                  createButton;
  private JButton                  saveButton;
  private JButton                  closeButton;

  private static PhotoPanel        currentPhotoPanel; // Support only one photo panel opened at a time
  private AbstractPhotoRenderer    photoRenderer;

  public PhotoPanel(Home home,
                    UserPreferences preferences,
                    PhotoController controller) {
    this(home, preferences, null, controller);
  }

  public PhotoPanel(Home home,
                    UserPreferences preferences,
                    Object3DFactory object3dFactory,
                    PhotoController controller) {
    super(new GridBagLayout());
    this.home = home;
    this.preferences = preferences;
    this.object3dFactory = object3dFactory;
    this.controller = controller;
    createActions(preferences);
    createComponents(home, preferences, controller);
    setMnemonics(preferences);
    layoutComponents();

    preferences.addPropertyChangeListener(UserPreferences.Property.LANGUAGE, new LanguageChangeListener(this));
  }

  /**
   * Creates actions for variables.
   */
  private void createActions(UserPreferences preferences) {
    final ActionMap actions = getActionMap();
    actions.put(ActionType.START_PHOTO_CREATION,
        new ResourceAction(preferences, PhotoPanel.class, ActionType.START_PHOTO_CREATION.name(), true) {
          @Override
          public void actionPerformed(ActionEvent ev) {
            startPhotoCreation();
          }
        });
    actions.put(ActionType.STOP_PHOTO_CREATION,
        new ResourceAction(preferences, PhotoPanel.class, ActionType.STOP_PHOTO_CREATION.name(), true) {
          @Override
          public void actionPerformed(ActionEvent ev) {
            stopPhotoCreation(true);
          }
        });
    actions.put(ActionType.SAVE_PHOTO,
        new ResourceAction(preferences, PhotoPanel.class, ActionType.SAVE_PHOTO.name(), false) {
          @Override
          public void actionPerformed(ActionEvent ev) {
            savePhoto();
          }
        });
    actions.put(ActionType.CLOSE,
        new ResourceAction(preferences, PhotoPanel.class, ActionType.CLOSE.name(), true) {
          @Override
          public void actionPerformed(ActionEvent ev) {
            close();
          }
        });
  }

  /**
   * Creates and initializes components.
   */
  private void createComponents(final Home home,
                                final UserPreferences preferences,
                                final PhotoController controller) {
    this.photoComponent = new ScaledImageComponent();
    this.photoComponent.setPreferredSize(new Dimension(getToolkit().getScreenSize().width <= 1024 ? 320 : 400, 320));
    // Under Mac OS X, set a transfer handler and a mouse listener on photo component
    // to let the user drag and drop the created image (Windows support seems to fail)
    if (OperatingSystem.isMacOSX()
        && !OperatingSystem.isJavaVersionBetween("1.7", "1.7.0_60")) {
      this.photoComponent.setTransferHandler(new VisualTransferHandler() {
          @Override
          public int getSourceActions(JComponent component) {
            return COPY_OR_MOVE;
          }

          @Override
          protected Transferable createTransferable(JComponent component) {
            return new Transferable() {
                public Object getTransferData(DataFlavor flavor) {
                  return photoComponent.getImage();
                }

                public DataFlavor [] getTransferDataFlavors() {
                  return new DataFlavor [] {DataFlavor.imageFlavor};
                }

                public boolean isDataFlavorSupported(DataFlavor flavor) {
                  return flavor.equals(DataFlavor.imageFlavor);
                }
              };
          }

          @Override
          public Icon getVisualRepresentation(Transferable transferable) {
            try {
              if (transferable.isDataFlavorSupported(DataFlavor.imageFlavor)) {
                // Create a 128x128 icon from the transfered image
                BufferedImage transferedImage =
                    (BufferedImage)transferable.getTransferData(DataFlavor.imageFlavor);
                float scale = Math.min(1, 128f / Math.max(transferedImage.getWidth(), transferedImage.getHeight()));
                BufferedImage iconImage = new BufferedImage(128, 128, BufferedImage.TYPE_INT_ARGB);
                Graphics2D g2D = (Graphics2D)iconImage.getGraphics();
                g2D.scale(scale, scale);
                g2D.drawRenderedImage(transferedImage, null);
                g2D.dispose();
                return new ImageIcon(iconImage);
              }
            } catch (UnsupportedFlavorException ex) {
              // Use default representation
            } catch (IOException ex) {
              // Use default representation
            }
            return super.getVisualRepresentation(transferable);
          }
        });
      this.photoComponent.addMouseListener(new MouseAdapter() {
          @Override
          public void mousePressed(MouseEvent ev) {
            if (SwingUtilities.isLeftMouseButton(ev)
                && photoComponent.getImage() != null
                && photoComponent.isPointInImage(ev.getX(), ev.getY())) {
              photoComponent.getTransferHandler().exportAsDrag(photoComponent, ev, TransferHandler.COPY);
            }
          }
        });
    }

    this.animatedWaitLabel = new JLabel(new ImageIcon(PhotoPanel.class.getResource("resources/animatedWait.gif")));

    // Create size and quality panel
    this.sizeAndQualityPanel = new PhotoSizeAndQualityPanel(home, preferences, controller);
    controller.addPropertyChangeListener(AbstractPhotoController.Property.QUALITY,
        new PropertyChangeListener() {
          public void propertyChange(PropertyChangeEvent ev) {
            updateAdvancedComponents();
          }
        });

    this.advancedComponentsSeparator = new JSeparator();

    // Create date and time labels and spinners bound to TIME controller property
    Date time = new Date(Camera.convertTimeToTimeZone(controller.getTime(), TimeZone.getDefault().getID()));
    this.dateLabel = new JLabel();
    final SpinnerDateModel dateSpinnerModel = new SpinnerDateModel();
    dateSpinnerModel.setValue(time);
    this.dateSpinner = new JSpinner(dateSpinnerModel);
    String datePattern = ((SimpleDateFormat)DateFormat.getDateInstance(DateFormat.SHORT)).toPattern();
    if (datePattern.indexOf("yyyy") == -1) {
      datePattern = datePattern.replace("yy", "yyyy");
    }
    JSpinner.DateEditor dateEditor = new JSpinner.DateEditor(this.dateSpinner, datePattern);
    this.dateSpinner.setEditor(dateEditor);
    SwingTools.addAutoSelectionOnFocusGain(dateEditor.getTextField());

    this.timeLabel = new JLabel();
    final SpinnerDateModel timeSpinnerModel = new SpinnerDateModel();
    timeSpinnerModel.setValue(time);
    this.timeSpinner = new JSpinner(timeSpinnerModel);
    // From http://en.wikipedia.org/wiki/12-hour_clock#Use_by_country
    String [] twelveHoursCountries = {
        "AU",  // Australia
        "BD",  // Bangladesh
        "CA",  // Canada (excluding Quebec, in French)
        "CO",  // Colombia
        "EG",  // Egypt
        "HN",  // Honduras
        "JO",  // Jordan
        "MX",  // Mexico
        "MY",  // Malaysia
        "NI",  // Nicaragua
        "NZ",  // New Zealand
        "PH",  // Philippines
        "PK",  // Pakistan
        "SA",  // Saudi Arabia
        "SV",  // El Salvador
        "US",  // United States
        "VE"}; // Venezuela
    SimpleDateFormat timeInstance;
    if ("en".equals(Locale.getDefault().getLanguage())) {
      if (Arrays.binarySearch(twelveHoursCountries, Locale.getDefault().getCountry()) >= 0) {
        timeInstance = (SimpleDateFormat)DateFormat.getTimeInstance(DateFormat.SHORT, Locale.US); // 12 hours notation
      } else {
        timeInstance = (SimpleDateFormat)DateFormat.getTimeInstance(DateFormat.SHORT, Locale.UK); // 24 hours notation
      }
    } else {
      timeInstance = (SimpleDateFormat)DateFormat.getTimeInstance(DateFormat.SHORT);
    }
    JSpinner.DateEditor timeEditor = new JSpinner.DateEditor(this.timeSpinner, timeInstance.toPattern());
    this.timeSpinner.setEditor(timeEditor);
    SwingTools.addAutoSelectionOnFocusGain(timeEditor.getTextField());

    final PropertyChangeListener timeChangeListener = new PropertyChangeListener() {
      public void propertyChange(PropertyChangeEvent ev) {
        Date date = new Date(Camera.convertTimeToTimeZone(controller.getTime(), TimeZone.getDefault().getID()));
        dateSpinnerModel.setValue(date);
        timeSpinnerModel.setValue(date);
      }
    };
    controller.addPropertyChangeListener(PhotoController.Property.TIME, timeChangeListener);
    final ChangeListener dateTimeChangeListener = new ChangeListener() {
        public void stateChanged(ChangeEvent ev) {
          controller.removePropertyChangeListener(PhotoController.Property.TIME, timeChangeListener);
          // Merge date and time
          GregorianCalendar dateCalendar = new GregorianCalendar();
          dateCalendar.setTime((Date)dateSpinnerModel.getValue());
          GregorianCalendar timeCalendar = new GregorianCalendar();
          timeCalendar.setTime((Date)timeSpinnerModel.getValue());
          Calendar utcCalendar = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
          utcCalendar.set(GregorianCalendar.YEAR, dateCalendar.get(GregorianCalendar.YEAR));
          utcCalendar.set(GregorianCalendar.MONTH, dateCalendar.get(GregorianCalendar.MONTH));
          utcCalendar.set(GregorianCalendar.DAY_OF_MONTH, dateCalendar.get(GregorianCalendar.DAY_OF_MONTH));
          utcCalendar.set(GregorianCalendar.HOUR_OF_DAY, timeCalendar.get(GregorianCalendar.HOUR_OF_DAY));
          utcCalendar.set(GregorianCalendar.MINUTE, timeCalendar.get(GregorianCalendar.MINUTE));
          utcCalendar.set(GregorianCalendar.SECOND, timeCalendar.get(GregorianCalendar.SECOND));
          controller.setTime(utcCalendar.getTimeInMillis());
          controller.addPropertyChangeListener(PhotoController.Property.TIME, timeChangeListener);
        }
      };
    dateSpinnerModel.addChangeListener(dateTimeChangeListener);
    timeSpinnerModel.addChangeListener(dateTimeChangeListener);

    this.dayNightLabel = new JLabel();
    final ImageIcon dayIcon = SwingTools.getScaledImageIcon(PhotoPanel.class.getResource("resources/day.png"));
    final ImageIcon nightIcon = SwingTools.getScaledImageIcon(PhotoPanel.class.getResource("resources/night.png"));
    PropertyChangeListener dayNightListener = new PropertyChangeListener() {
        public void propertyChange(PropertyChangeEvent ev) {
          if (home.getCompass().getSunElevation(
                Camera.convertTimeToTimeZone(controller.getTime(), home.getCompass().getTimeZone())) > 0) {
            dayNightLabel.setIcon(dayIcon);
          } else {
            dayNightLabel.setIcon(nightIcon);
          }
        }
      };
    controller.addPropertyChangeListener(PhotoController.Property.TIME, dayNightListener);
    home.getCompass().addPropertyChangeListener(dayNightListener);
    dayNightListener.propertyChange(null);

    // Create lens label and combo box
    this.lensLabel = new JLabel();
    this.lensComboBox = new JComboBox(Camera.Lens.values());
    this.lensComboBox.setRenderer(new DefaultListCellRenderer() {
        @Override
        public Component getListCellRendererComponent(JList list, Object value,
                                                      int index, boolean isSelected, boolean cellHasFocus) {
          String displayedValue;
          switch ((Camera.Lens)value) {
            case NORMAL :
              displayedValue = preferences.getLocalizedString(PhotoPanel.class, "lensComboBox.normalLens.text");
              break;
            case SPHERICAL :
              displayedValue = preferences.getLocalizedString(PhotoPanel.class, "lensComboBox.sphericalLens.text");
              break;
            case FISHEYE :
              displayedValue = preferences.getLocalizedString(PhotoPanel.class, "lensComboBox.fisheyeLens.text");
              break;
            case PINHOLE :
            default :
              displayedValue = preferences.getLocalizedString(PhotoPanel.class, "lensComboBox.pinholeLens.text");
              break;
          }
          return super.getListCellRendererComponent(list, displayedValue, index, isSelected,
              cellHasFocus);
        }
      });
    this.lensComboBox.setSelectedItem(controller.getLens());
    controller.addPropertyChangeListener(PhotoController.Property.LENS,
        new PropertyChangeListener() {
          public void propertyChange(PropertyChangeEvent ev) {
            lensComboBox.setSelectedItem(controller.getLens());
          }
        });
    this.lensComboBox.addItemListener(new ItemListener() {
        public void itemStateChanged(ItemEvent ev) {
          Camera.Lens lens = (Camera.Lens)lensComboBox.getSelectedItem();
          controller.setLens(lens);
          if (lens == Camera.Lens.SPHERICAL) {
            controller.setAspectRatio(AspectRatio.RATIO_2_1);
          } else if (lens == Camera.Lens.FISHEYE) {
            controller.setAspectRatio(AspectRatio.SQUARE_RATIO);
          }
          updateRatioComponents();
        }
      });

    this.ceilingLightEnabledCheckBox = new JCheckBox();
    this.ceilingLightEnabledCheckBox.setSelected(controller.getCeilingLightColor() > 0);
    controller.addPropertyChangeListener(AbstractPhotoController.Property.CEILING_LIGHT_COLOR,
        new PropertyChangeListener() {
          public void propertyChange(PropertyChangeEvent ev) {
            ceilingLightEnabledCheckBox.setSelected(controller.getCeilingLightColor() > 0);
          }
        });
    this.ceilingLightEnabledCheckBox.addItemListener(new ItemListener() {
        public void itemStateChanged(ItemEvent ev) {
          controller.setCeilingLightColor(ceilingLightEnabledCheckBox.isSelected() ? 0xD0D0D0 : 0);
        }
      });

    this.rendererLabel = new JLabel();
    this.rendererComboBox = new JComboBox(AbstractPhotoRenderer.getAvailableRenderers().toArray());
    this.rendererComboBox.setRenderer(new DefaultListCellRenderer() {
        @Override
        public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected,
                                                      boolean cellHasFocus) {
          String photoRenderer = AbstractPhotoRenderer.createInstance((String)value, home, object3dFactory, AbstractPhotoRenderer.Quality.LOW).getName();
          return super.getListCellRendererComponent(list, photoRenderer, index, isSelected, cellHasFocus);
        }
      });
    this.rendererComboBox.setSelectedItem(controller.getRenderer());
    controller.addPropertyChangeListener(PhotoController.Property.RENDERER,
        new PropertyChangeListener() {
          public void propertyChange(PropertyChangeEvent ev) {
            rendererComboBox.setSelectedItem(controller.getRenderer());
          }
        });
    this.rendererComboBox.addItemListener(new ItemListener() {
        public void itemStateChanged(ItemEvent ev) {
          String renderer = (String)rendererComboBox.getSelectedItem();
          controller.setRenderer(renderer);
        }
      });

    final JComponent view3D = (JComponent)controller.get3DView();
    controller.set3DViewAspectRatio((float)view3D.getWidth() / view3D.getHeight());

    final ActionMap actionMap = getActionMap();
    this.createButton = new JButton(actionMap.get(ActionType.START_PHOTO_CREATION));
    this.saveButton = new JButton(actionMap.get(ActionType.SAVE_PHOTO));
    this.closeButton = new JButton(actionMap.get(ActionType.CLOSE));

    setComponentTexts(preferences);
    updateRatioComponents();
  }

  /**
   * Sets the texts of the components.
   */
  private void setComponentTexts(UserPreferences preferences) {
    this.dateLabel.setText(SwingTools.getLocalizedLabelText(preferences,
        PhotoPanel.class, "dateLabel.text"));
    this.timeLabel.setText(SwingTools.getLocalizedLabelText(preferences,
        PhotoPanel.class, "timeLabel.text"));
    this.lensLabel.setText(SwingTools.getLocalizedLabelText(preferences,
        PhotoPanel.class, "lensLabel.text"));
    this.ceilingLightEnabledCheckBox.setText(SwingTools.getLocalizedLabelText(preferences,
        PhotoPanel.class, "ceilingLightEnabledCheckBox.text"));
    this.rendererLabel.setText(SwingTools.getLocalizedLabelText(preferences,
        PhotoPanel.class, "rendererLabel.text"));
    this.dialogTitle = preferences.getLocalizedString(PhotoPanel.class, "createPhoto.title");
    Window window = SwingUtilities.getWindowAncestor(this);
    if (window != null) {
      ((JDialog)window).setTitle(this.dialogTitle);
    }
    // Buttons text changes automatically through their action
  }

  /**
   * Sets components mnemonics and label / component associations.
   */
  private void setMnemonics(UserPreferences preferences) {
    if (!OperatingSystem.isMacOSX()) {
      this.dateLabel.setDisplayedMnemonic(KeyStroke.getKeyStroke(preferences.getLocalizedString(
          PhotoPanel.class, "dateLabel.mnemonic")).getKeyCode());
      this.dateLabel.setLabelFor(this.dateSpinner);
      this.timeLabel.setDisplayedMnemonic(KeyStroke.getKeyStroke(preferences.getLocalizedString(
          PhotoPanel.class, "timeLabel.mnemonic")).getKeyCode());
      this.timeLabel.setLabelFor(this.timeSpinner);
      this.lensLabel.setDisplayedMnemonic(KeyStroke.getKeyStroke(preferences.getLocalizedString(
          PhotoPanel.class, "lensLabel.mnemonic")).getKeyCode());
      this.lensLabel.setLabelFor(this.lensComboBox);
      this.ceilingLightEnabledCheckBox.setMnemonic(KeyStroke.getKeyStroke(preferences.getLocalizedString(
          PhotoPanel.class, "ceilingLightEnabledCheckBox.mnemonic")).getKeyCode());
      this.rendererLabel.setDisplayedMnemonic(KeyStroke.getKeyStroke(preferences.getLocalizedString(
          PhotoPanel.class, "rendererLabel.mnemonic")).getKeyCode());
      this.rendererLabel.setLabelFor(this.rendererComboBox);
    }
  }

  /**
   * Preferences property listener bound to this panel with a weak reference to avoid
   * strong link between user preferences and this panel.
   */
  public static class LanguageChangeListener implements PropertyChangeListener {
    private final WeakReference<PhotoPanel> photoPanel;

    public LanguageChangeListener(PhotoPanel photoPanel) {
      this.photoPanel = new WeakReference<PhotoPanel>(photoPanel);
    }

    public void propertyChange(PropertyChangeEvent ev) {
      // If photo panel was garbage collected, remove this listener from preferences
      PhotoPanel photoPanel = this.photoPanel.get();
      UserPreferences preferences = (UserPreferences)ev.getSource();
      if (photoPanel == null) {
        preferences.removePropertyChangeListener(UserPreferences.Property.LANGUAGE, this);
      } else {
        photoPanel.applyComponentOrientation(ComponentOrientation.getOrientation(Locale.getDefault()));
        photoPanel.setComponentTexts(preferences);
        photoPanel.setMnemonics(preferences);
      }
    }
  }

  /**
   * Layouts panel components in panel with their labels.
   */
  private void layoutComponents() {
    int labelAlignment = OperatingSystem.isMacOSX()
        ? JLabel.TRAILING
        : JLabel.LEADING;
    // Add animatedWaitLabel and photoComponent to a card panel
    this.photoCardLayout = new CardLayout();
    this.photoPanel = new JPanel(this.photoCardLayout);
    photoPanel.add(this.photoComponent, PHOTO_CARD);
    photoPanel.add(this.animatedWaitLabel, WAIT_CARD);
    int standardGap = Math.round(5 * SwingTools.getResolutionScale());
    // First row
    add(this.photoPanel, new GridBagConstraints(
        0, 0, 3, 1, 1, 1, GridBagConstraints.CENTER,
        GridBagConstraints.BOTH, new Insets(0, 0, standardGap, 0), 0, 0));
    // Second row
    // Add a dummy label at left and right
    add(new JLabel(), new GridBagConstraints(
        0, 1, 1, 8, 0.5f, 0, GridBagConstraints.CENTER,
        GridBagConstraints.BOTH, new Insets(0, 0, 0, 0), 0, 0));
    add(new JLabel(), new GridBagConstraints(
        2, 1, 1, 8, 0.5f, 0, GridBagConstraints.CENTER,
        GridBagConstraints.BOTH, new Insets(0, 0, 0, 0), 0, 0));
    add(this.sizeAndQualityPanel, new GridBagConstraints(
        1, 1, 1, 1, 0, 0, GridBagConstraints.CENTER,
        GridBagConstraints.NONE, new Insets(0, 0, 0, 0), 0, 0));
    // Third row
    add(this.advancedComponentsSeparator, new GridBagConstraints(
        1, 2, 1, 1, 0, 0, GridBagConstraints.CENTER,
        GridBagConstraints.HORIZONTAL, new Insets(3, 0, 3, 0), 0, 0));
    // Forth row
    JPanel advancedPanel = new JPanel(new GridBagLayout());
    advancedPanel.add(this.dateLabel, new GridBagConstraints(
        0, 1, 1, 1, 0, 0, GridBagConstraints.CENTER,
        GridBagConstraints.HORIZONTAL, new Insets(0, 0, standardGap, standardGap), 0, 0));
    this.dateLabel.setHorizontalAlignment(labelAlignment);
    advancedPanel.add(this.dateSpinner, new GridBagConstraints(
        1, 1, 1, 1, 1, 0, GridBagConstraints.LINE_START,
        GridBagConstraints.HORIZONTAL, new Insets(0, 0, standardGap, 10), 0, 0));
    advancedPanel.add(this.timeLabel, new GridBagConstraints(
        2, 1, 1, 1, 0, 0, GridBagConstraints.CENTER,
        GridBagConstraints.HORIZONTAL, new Insets(0, 0, standardGap, standardGap), 0, 0));
    this.timeLabel.setHorizontalAlignment(labelAlignment);
    advancedPanel.add(this.timeSpinner, new GridBagConstraints(
        3, 1, 1, 1, 1, 0, GridBagConstraints.LINE_START,
        GridBagConstraints.HORIZONTAL, new Insets(0, 0, standardGap, standardGap), 0, 0));
    advancedPanel.add(this.dayNightLabel, new GridBagConstraints(
        4, 1, 1, 1, 0, 0, GridBagConstraints.LINE_START,
        GridBagConstraints.NONE, new Insets(0, 0, standardGap, 0), 0, 0));
    // Fifth row
    advancedPanel.add(this.lensLabel, new GridBagConstraints(
        0, 2, 1, 1, 0, 0, GridBagConstraints.CENTER,
        GridBagConstraints.HORIZONTAL, new Insets(0, 0, standardGap, standardGap), 0, 0));
    this.lensLabel.setHorizontalAlignment(labelAlignment);
    advancedPanel.add(this.lensComboBox, new GridBagConstraints(
        1, 2, 1, 1, 0, 0, GridBagConstraints.LINE_START,
        GridBagConstraints.HORIZONTAL, new Insets(0, 0, standardGap, 10), 0, 0));
    advancedPanel.add(this.ceilingLightEnabledCheckBox, new GridBagConstraints(
        2, 2, 3, 1, 0, 0, GridBagConstraints.CENTER,
        GridBagConstraints.NONE, new Insets(0, 0, standardGap, 0), 0, 0));
    // Last row
    advancedPanel.add(this.rendererLabel, new GridBagConstraints(
        0, 3, 1, 1, 0, 0, GridBagConstraints.CENTER,
        GridBagConstraints.HORIZONTAL, new Insets(0, 0, 0, standardGap), 0, 0));
    this.rendererLabel.setHorizontalAlignment(labelAlignment);
    advancedPanel.add(this.rendererComboBox, new GridBagConstraints(
        1, 3, 1, 1, 0, 0, GridBagConstraints.LINE_START,
        GridBagConstraints.HORIZONTAL, new Insets(0, 0, 0, 10), 0, 0));

    add(advancedPanel, new GridBagConstraints(
        1, 3, 1, 1, 0, 0, GridBagConstraints.CENTER,
        GridBagConstraints.HORIZONTAL, new Insets(0, 0, 0, 0), 0, 0));
  }

  private void updateAdvancedComponents() {
    Component root = SwingUtilities.getRoot(this);
    if (root != null) {
      boolean highQuality = controller.getQuality() >= 2;
      boolean advancedComponentsVisible = this.advancedComponentsSeparator.isVisible();
      if (advancedComponentsVisible != highQuality) {
        setAdvancedComponentsVisible(highQuality);
        updateRatioComponents();
        int componentsHeight = this.advancedComponentsSeparator.getPreferredSize().height + 6
            + this.dateSpinner.getPreferredSize().height + 5
            + this.lensComboBox.getPreferredSize().height;
        root.setSize(root.getWidth(),
            root.getHeight() + (advancedComponentsVisible ? -componentsHeight : componentsHeight));
      }
    }
  }

  private void setAdvancedComponentsVisible(boolean visible) {
    this.advancedComponentsSeparator.setVisible(visible);
    this.dateLabel.setVisible(visible);
    this.dateSpinner.setVisible(visible);
    this.timeLabel.setVisible(visible);
    this.timeSpinner.setVisible(visible);
    this.dayNightLabel.setVisible(visible);
    this.lensLabel.setVisible(visible);
    this.lensComboBox.setVisible(visible);
    this.ceilingLightEnabledCheckBox.setVisible(visible);
    this.rendererLabel.setVisible(visible);
    this.rendererComboBox.setVisible(visible);
  }

  /**
   * Updates photo height.
   */
  private void updateRatioComponents() {
    Lens lens = this.controller.getLens();
    boolean fixedProportions = this.lensComboBox.isVisible()
        && (lens == Camera.Lens.FISHEYE
            || lens == Camera.Lens.SPHERICAL);
    this.sizeAndQualityPanel.setProportionsChoiceEnabled(!fixedProportions);
  }

  /**
   * Displays this panel in a non modal dialog.
   */
  public void displayView(View parentView) {
    if (currentPhotoPanel == this) {
      SwingUtilities.getWindowAncestor(PhotoPanel.this).toFront();
    } else {
      if (currentPhotoPanel != null) {
        currentPhotoPanel.close();
      }
      final JOptionPane optionPane = new JOptionPane(this,
          JOptionPane.PLAIN_MESSAGE, JOptionPane.OK_CANCEL_OPTION,
          null, new Object [] {this.createButton, this.saveButton, this.closeButton}, this.createButton);
      final JDialog dialog = optionPane.createDialog(SwingUtilities.getRootPane((Component)parentView), this.dialogTitle);
      dialog.applyComponentOrientation(parentView != null
          ? ((JComponent)parentView).getComponentOrientation()
          : ComponentOrientation.getOrientation(Locale.getDefault()));
      dialog.setModal(false);

      Component homeRoot = SwingUtilities.getRoot((Component)parentView);
      Point dialogLocation = null;
      if (homeRoot != null) {
        // Restore location if it exists
        Number x = this.home.getNumericProperty(PHOTO_DIALOG_X_VISUAL_PROPERTY);
        Number y = this.home.getNumericProperty(PHOTO_DIALOG_Y_VISUAL_PROPERTY);
        Number width = this.home.getNumericProperty(PHOTO_DIALOG_WIDTH_VISUAL_PROPERTY);
        Number height = this.home.getNumericProperty(PHOTO_DIALOG_HEIGHT_VISUAL_PROPERTY);

        int windowRightBorder = homeRoot.getX() + homeRoot.getWidth();
        Dimension screenSize = getToolkit().getScreenSize();
        Insets screenInsets = getToolkit().getScreenInsets(getGraphicsConfiguration());
        int screenRightBorder = screenSize.width - screenInsets.right;
        // Check dialog isn't too large
        int screenWidth = screenSize.width - screenInsets.left - screenInsets.right;
        int screenHeight = screenSize.height - screenInsets.top - screenInsets.bottom;
        if (OperatingSystem.isLinux() && screenHeight == screenSize.height) {
          // Let's consider that under Linux at least an horizontal bar exists
          screenHeight -= 30;
        }
        int screenBottomBorder = screenSize.height - screenInsets.bottom;
        int dialogWidth = width != null
            ? Math.min(width.intValue(), screenWidth)
            : dialog.getWidth();
        int dialogHeight = height != null
            ? Math.min(height.intValue(), screenHeight)
            : dialog.getHeight();
        dialog.setSize(dialogWidth, dialogHeight);

        if (x != null && y != null
            && x.intValue() + dialogWidth <= screenRightBorder
            && y.intValue() + dialogHeight <= screenBottomBorder) {
          dialogLocation = new Point(x.intValue(), y.intValue());
        } else if (screenRightBorder - windowRightBorder > dialogWidth / 2
                   || dialogHeight == screenHeight) {
          // If there some space left at the right of the window,
          // move the dialog to the right of window
          dialogLocation = new Point(Math.min(windowRightBorder + 5, screenRightBorder - dialogWidth),
              Math.max(Math.min(homeRoot.getY(), screenSize.height - dialogHeight - screenInsets.bottom), screenInsets.top));
        }
      }
      if (dialogLocation != null
          && SwingTools.isRectangleVisibleAtScreen(new Rectangle(dialogLocation, dialog.getSize()))) {
        dialog.setLocationByPlatform(false);
        dialog.setLocation(dialogLocation);
      } else {
        dialog.setLocationByPlatform(true);
      }

      dialog.addWindowListener(new WindowAdapter() {
          public void windowClosed(WindowEvent ev) {
            stopPhotoCreation(false);
            currentPhotoPanel = null;
          }
        });

      updateAdvancedComponents();
      dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
      dialog.addComponentListener(new ComponentAdapter() {
          @Override
          public void componentHidden(ComponentEvent ev) {
            if (optionPane.getValue() != null
                && optionPane.getValue() != JOptionPane.UNINITIALIZED_VALUE) {
              close();
            }
          }

          @Override
          public void componentMoved(ComponentEvent ev) {
            controller.setHomeProperty(PHOTO_DIALOG_X_VISUAL_PROPERTY, String.valueOf(dialog.getX()));
            controller.setHomeProperty(PHOTO_DIALOG_Y_VISUAL_PROPERTY, String.valueOf(dialog.getY()));
          }

          @Override
          public void componentResized(ComponentEvent ev) {
            controller.setHomeProperty(PHOTO_DIALOG_WIDTH_VISUAL_PROPERTY, String.valueOf(dialog.getWidth()));
            controller.setHomeProperty(PHOTO_DIALOG_HEIGHT_VISUAL_PROPERTY, String.valueOf(dialog.getHeight()));
          }
        });
      dialog.setResizable(true);
      dialog.setVisible(true);
      currentPhotoPanel = this;
    }
  }

  /**
   * Creates the photo image depending on the quality requested by the user.
   */
  private void startPhotoCreation() {
    this.photoComponent.setImage(null);
    this.sizeAndQualityPanel.setEnabled(false);
    this.dateSpinner.setEnabled(false);
    this.timeSpinner.setEnabled(false);
    this.lensComboBox.setEnabled(false);
    this.ceilingLightEnabledCheckBox.setEnabled(false);
    this.rendererComboBox.setEnabled(false);
    getActionMap().get(ActionType.SAVE_PHOTO).setEnabled(false);
    getRootPane().setDefaultButton(this.createButton);
    this.createButton.setAction(getActionMap().get(ActionType.STOP_PHOTO_CREATION));
    this.photoCardLayout.show(this.photoPanel, WAIT_CARD);

    // Compute photo in an other executor thread
    // Use a clone of home because the user can modify home during photo computation
    final Home home = this.home.clone();
    List<Selectable> emptySelection = Collections.emptyList();
    home.setSelectedItems(emptySelection);
    this.photoCreationExecutor = Executors.newSingleThreadExecutor();
    this.photoCreationExecutor.execute(new Runnable() {
        public void run() {
          computePhoto(home);
        }
      });
  }

  /**
   * Computes the photo of the given home.
   * Caution : this method must be thread safe because it's called from an executor.
   */
  private void computePhoto(Home home) {
    this.photoCreationStartTime = System.currentTimeMillis();
    BufferedImage image = null;
    int quality = this.controller.getQuality();
    int imageWidth = this.controller.getWidth();
    int imageHeight = this.controller.getHeight();
    try {
      if (quality >= 2) {
        this.photoRenderer = AbstractPhotoRenderer.createInstance(
            this.controller.getRenderer(), home, this.object3dFactory,
            quality == 2
                ? AbstractPhotoRenderer.Quality.LOW
                : AbstractPhotoRenderer.Quality.HIGH);
        int bestImageHeight;
        // Check correct ratio if lens is fisheye or spherical
        Camera camera = home.getCamera();
        if (camera.getLens() == Camera.Lens.FISHEYE) {
          bestImageHeight = imageWidth;
        } else if (camera.getLens() == Camera.Lens.SPHERICAL) {
          bestImageHeight = imageWidth / 2;
        } else {
          bestImageHeight = imageHeight;
        }
        if (photoCreationExecutor != null) {
          image = new BufferedImage(imageWidth, bestImageHeight, BufferedImage.TYPE_INT_RGB);
          this.photoComponent.setImage(image);
          EventQueue.invokeLater(new Runnable() {
            public void run() {
              photoCardLayout.show(photoPanel, PHOTO_CARD);
            }
          });
          this.photoRenderer.render(image, camera, this.photoComponent);
          if (this.photoRenderer != null) {
            this.photoRenderer.dispose();
            this.photoRenderer = null;
          }
        }
      } else {
        // Compute 3D view offscreen image
        HomeComponent3D homeComponent3D = new HomeComponent3D(
            home, this.preferences, this.object3dFactory,
            quality == 1
            && (!this.preferences.isDrawingModeEnabled()
                || home.getEnvironment().getDrawingMode() != HomeEnvironment.DrawingMode.OUTLINE),
            null);
        image = homeComponent3D.getOffScreenImage(imageWidth, imageHeight);
      }
    } catch (OutOfMemoryError ex) {
      image = getErrorImage();
      throw ex;
    } catch (IllegalStateException ex) {
      image = getErrorImage();
      throw ex;
    } catch (IOException ex) {
      image = getErrorImage();
    } finally {
      final BufferedImage photoImage = this.photoCreationExecutor != null
          ? image
          : null;
      EventQueue.invokeLater(new Runnable() {
          public void run() {
            getActionMap().get(ActionType.SAVE_PHOTO).setEnabled(photoImage != null);
            if (photoImage != null) {
              getRootPane().setDefaultButton(saveButton);
            }
            createButton.setAction(getActionMap().get(ActionType.START_PHOTO_CREATION));
            createButton.setEnabled(true);
            photoComponent.setImage(photoImage);
            sizeAndQualityPanel.setEnabled(true);
            updateRatioComponents();
            dateSpinner.setEnabled(true);
            timeSpinner.setEnabled(true);
            lensComboBox.setEnabled(true);
            rendererComboBox.setEnabled(true);
            ceilingLightEnabledCheckBox.setEnabled(true);
            photoCardLayout.show(photoPanel, PHOTO_CARD);
            photoCreationExecutor = null;
          }
        });
    }
  }

  /**
   * Returns the image used in case of an error.
   */
  private BufferedImage getErrorImage() {
    Icon errorIcon = IconManager.getInstance().getErrorIcon(16);
    BufferedImage errorImage = new BufferedImage(
        errorIcon.getIconWidth(), errorIcon.getIconHeight(), BufferedImage.TYPE_INT_ARGB);
    Graphics2D g2D = (Graphics2D)errorImage.getGraphics();
    errorIcon.paintIcon(this, g2D, 0, 0);
    g2D.dispose();
    return errorImage;
  }

  /**
   * Stops photo creation.
   */
  private void stopPhotoCreation(boolean confirmStop) {
    if (this.photoCreationExecutor != null
        // Confirm the stop if a rendering has been running for more than 30 s
        && (!confirmStop
            || System.currentTimeMillis() - this.photoCreationStartTime < MINIMUM_DELAY_BEFORE_DISCARDING_WITHOUT_WARNING
            || JOptionPane.showConfirmDialog(this,
                  this.preferences.getLocalizedString(PhotoPanel.class, "confirmStopCreation.message"),
                  this.preferences.getLocalizedString(PhotoPanel.class, "confirmStopCreation.title"),
                  JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE) == JOptionPane.YES_OPTION)) {
      if (this.photoCreationExecutor != null) { // Check a second time in case rendering stopped meanwhile
        // Will interrupt executor thread
        this.photoCreationExecutor.shutdownNow();
        this.photoCreationExecutor = null;
        if (this.photoRenderer != null) {
          this.photoRenderer.stop();
          this.photoRenderer = null;
        }
        this.createButton.setAction(getActionMap().get(ActionType.START_PHOTO_CREATION));
        // Disable button until the rendering process will actually end
        this.createButton.setEnabled(false);
      }
    }
  }

  /**
   * Saves the created image.
   */
  private void savePhoto() {
    String pngFile = this.controller.getContentManager().showSaveDialog(this,
        this.preferences.getLocalizedString(PhotoPanel.class, "savePhotoDialog.title"),
        ContentManager.ContentType.PNG, this.home.getName());
    if (pngFile != null) {
      try {
        // Avoid ImageIO.write(RenderedImage, String, File) which may throw NullPointerException if file can't be written
        FileImageOutputStream output = new FileImageOutputStream(new File(pngFile));
        ImageWriter writer = ImageIO.getImageWritersByFormatName("PNG").next();
        writer.setOutput(output);
        writer.write(this.photoComponent.getImage());
        writer.dispose();
        output.close();
      } catch (IOException ex) {
        String messageFormat = this.preferences.getLocalizedString(PhotoPanel.class, "savePhotoError.message");
        SwingTools.showMessageDialog(this, String.format(messageFormat, ex.getMessage()),
            this.preferences.getLocalizedString(PhotoPanel.class, "savePhotoError.title"), JOptionPane.ERROR_MESSAGE);
      }
    }
  }

  /**
   * Manages closing of this pane.
   */
  private void close() {
    Window window = SwingUtilities.getWindowAncestor(this);
    if (window.isDisplayable()) {
      window.dispose();
    }
  }
}
